chore: import upstream snapshot with attribution
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:32 +08:00
commit 4cd2d4af2b
475 changed files with 121829 additions and 0 deletions
+41
View File
@@ -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',
]
+125
View File
@@ -0,0 +1,125 @@
"""Per-CDP-request timeout wrapper around cdp_use.CDPClient.
cdp_use's `send_raw()` awaits a future that only resolves when the browser
sends a matching response. If the server goes silent mid-session (observed
failure mode against remote cloud browsers: WebSocket stays "alive" at the
TCP/keepalive layer while the browser container is dead or the proxy has
lost its upstream) the future never resolves and the whole agent hangs.
This module provides a thin subclass that wraps each `send_raw()` in
`asyncio.wait_for`. Any CDP method that doesn't get a response within the
cap raises `TimeoutError`, which propagates through existing
error-handling paths in browser-use instead of hanging indefinitely.
Configure the cap via:
- `BROWSER_USE_CDP_TIMEOUT_S` env var (process-wide default)
- `TimeoutWrappedCDPClient(..., cdp_request_timeout_s=...)` constructor arg
Default (60s) is generous for slow operations like `Page.captureScreenshot`
or `Page.printToPDF` on heavy pages, but well below the 180s agent step
timeout and the typical outer agent watchdog.
"""
from __future__ import annotations
import asyncio
import logging
import math
import os
from typing import Any
from cdp_use import CDPClient
logger = logging.getLogger(__name__)
_CDP_TIMEOUT_FALLBACK_S = 60.0
def _parse_env_cdp_timeout(raw: str | None) -> float:
"""Parse BROWSER_USE_CDP_TIMEOUT_S defensively.
Accepts only finite positive values; everything else falls back to the
hardcoded default with a warning. Mirrors the guard on
BROWSER_USE_ACTION_TIMEOUT_S in tools/service.py — a bad env value here
would otherwise make every CDP call time out immediately (nan) or never
(inf / negative / zero).
"""
if raw is None or raw == '':
return _CDP_TIMEOUT_FALLBACK_S
try:
parsed = float(raw)
except ValueError:
logger.warning(
'Invalid BROWSER_USE_CDP_TIMEOUT_S=%r; falling back to %.0fs',
raw,
_CDP_TIMEOUT_FALLBACK_S,
)
return _CDP_TIMEOUT_FALLBACK_S
if not math.isfinite(parsed) or parsed <= 0:
logger.warning(
'BROWSER_USE_CDP_TIMEOUT_S=%r is not a finite positive number; falling back to %.0fs',
raw,
_CDP_TIMEOUT_FALLBACK_S,
)
return _CDP_TIMEOUT_FALLBACK_S
return parsed
DEFAULT_CDP_REQUEST_TIMEOUT_S: float = _parse_env_cdp_timeout(os.getenv('BROWSER_USE_CDP_TIMEOUT_S'))
def _coerce_valid_timeout(value: float | None) -> float:
"""Normalize a user-supplied timeout to a finite positive value.
None / nan / inf / non-positive values all fall back to the env-derived
default with a warning. This mirrors _parse_env_cdp_timeout so callers that
pass cdp_request_timeout_s directly get the same defensive behaviour as
callers that set the env var.
"""
if value is None:
return DEFAULT_CDP_REQUEST_TIMEOUT_S
if not math.isfinite(value) or value <= 0:
logger.warning(
'cdp_request_timeout_s=%r is not a finite positive number; falling back to %.0fs',
value,
DEFAULT_CDP_REQUEST_TIMEOUT_S,
)
return DEFAULT_CDP_REQUEST_TIMEOUT_S
return float(value)
class TimeoutWrappedCDPClient(CDPClient):
"""CDPClient subclass that enforces a per-request timeout on send_raw.
Any CDP method that doesn't receive a response within `cdp_request_timeout_s`
raises `TimeoutError` instead of hanging forever. This turns silent-hang
failure modes (cloud proxy alive, browser dead) into fast observable errors.
"""
def __init__(
self,
*args: Any,
cdp_request_timeout_s: float | None = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self._cdp_request_timeout_s: float = _coerce_valid_timeout(cdp_request_timeout_s)
async def send_raw(
self,
method: str,
params: Any | None = None,
session_id: str | None = None,
) -> dict[str, Any]:
try:
return await asyncio.wait_for(
super().send_raw(method=method, params=params, session_id=session_id),
timeout=self._cdp_request_timeout_s,
)
except TimeoutError as e:
# Raise a plain TimeoutError so existing `except TimeoutError`
# handlers in browser-use / tools treat this uniformly.
raise TimeoutError(
f'CDP method {method!r} did not respond within {self._cdp_request_timeout_s:.0f}s. '
f'The browser may be unresponsive (silent WebSocket — container crashed or proxy lost upstream).'
) from e
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import os
import platform
import subprocess
from pathlib import Path
def _chrome_user_data_dir_for_executable(executable_path: str | None) -> Path | None:
if executable_path is None:
return None
system = platform.system()
path = Path(executable_path)
if system == 'Darwin':
app_path = str(path)
base = Path.home() / 'Library' / 'Application Support'
if 'Chromium.app' in app_path:
return base / 'Chromium'
if 'Google Chrome Canary.app' in app_path:
return base / 'Google' / 'Chrome Canary'
if 'Google Chrome.app' in app_path:
return base / 'Google' / 'Chrome'
if system == 'Linux':
name = path.name
base = Path.home() / '.config'
if name in {'chromium', 'chromium-browser'}:
return base / 'chromium'
if name in {'google-chrome', 'google-chrome-stable'}:
return base / 'google-chrome'
if system == 'Windows' and path.name.lower() == 'chrome.exe':
return Path(os.path.expandvars(r'%LocalAppData%\Google\Chrome\User Data'))
return None
def find_chrome_executable() -> str | None:
"""Find Chrome/Chromium executable on the system."""
system = platform.system()
if system == 'Darwin':
for path in (
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
):
if os.path.exists(path):
return path
elif system == 'Linux':
for cmd in ('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'):
try:
result = subprocess.run(['which', cmd], capture_output=True, text=True)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
elif system == 'Windows':
for path in (
os.path.expandvars(r'%ProgramFiles%\Google\Chrome\Application\chrome.exe'),
os.path.expandvars(r'%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe'),
os.path.expandvars(r'%LocalAppData%\Google\Chrome\Application\chrome.exe'),
):
if os.path.exists(path):
return path
return None
def get_chrome_profile_path(profile: str | None, executable_path: str | None = None) -> str | None:
"""Get Chrome user data directory, or return a specific profile directory name."""
if profile is not None:
return profile
if browser_user_data_dir := _chrome_user_data_dir_for_executable(executable_path):
return str(browser_user_data_dir)
system = platform.system()
if system == 'Darwin':
return str(Path.home() / 'Library' / 'Application Support' / 'Google' / 'Chrome')
if system == 'Linux':
base = Path.home() / '.config'
for name in ('google-chrome', 'chromium'):
if (base / name).is_dir():
return str(base / name)
return str(base / 'google-chrome')
if system == 'Windows':
return os.path.expandvars(r'%LocalAppData%\Google\Chrome\User Data')
return None
def list_chrome_profiles() -> list[dict[str, str]]:
"""List available Chrome profiles with their display names."""
user_data_dir = get_chrome_profile_path(None, executable_path=find_chrome_executable())
if user_data_dir is None:
return []
local_state_path = Path(user_data_dir) / 'Local State'
if not local_state_path.exists():
return []
try:
with open(local_state_path, encoding='utf-8') as f:
local_state = json.load(f)
if not isinstance(local_state, dict):
return []
info_cache = local_state.get('profile', {}).get('info_cache', {})
if not isinstance(info_cache, dict):
return []
return sorted(
[
{
'directory': directory,
'name': info.get('name', directory),
}
for directory, info in info_cache.items()
if isinstance(info, dict)
],
key=lambda profile: profile['directory'],
)
except (json.JSONDecodeError, KeyError, OSError, TypeError):
return []
+210
View File
@@ -0,0 +1,210 @@
"""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 browser_use.browser.cloud.views import CloudBrowserAuthError, CloudBrowserError, CloudBrowserResponse, CreateBrowserRequest
from browser_use.sync.auth import CloudAuthConfig
logger = logging.getLogger(__name__)
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, request: CreateBrowserRequest, extra_headers: dict[str, str] | None = None
) -> CloudBrowserResponse:
"""Create a new cloud browser instance. For full docs refer to https://docs.cloud.browser-use.com/api-reference/v-2-api-current/browsers/create-browser-session-browsers-post
Args:
request: CreateBrowserRequest object containing browser creation parameters
Returns:
CloudBrowserResponse: Contains CDP URL and other browser info
"""
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(
'BROWSER_USE_API_KEY is not set. To use cloud browsers, get a key at:\n'
'https://cloud.browser-use.com/new-api-key?utm_source=oss&utm_medium=use_cloud'
)
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json', **(extra_headers or {})}
# Convert request to dictionary and exclude unset fields
request_body = request.model_dump(exclude_unset=True)
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(
'BROWSER_USE_API_KEY is invalid. Get a new key at:\n'
'https://cloud.browser-use.com/new-api-key?utm_source=oss&utm_medium=use_cloud'
)
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, extra_headers: dict[str, 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(
'BROWSER_USE_API_KEY is not set. To use cloud browsers, get a key at:\n'
'https://cloud.browser-use.com/new-api-key?utm_source=oss&utm_medium=use_cloud'
)
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json', **(extra_headers or {})}
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.
Safe to call multiple times — subsequent calls are no-ops.
"""
# 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}')
if not self.client.is_closed:
await self.client.aclose()
+96
View File
@@ -0,0 +1,96 @@
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
ProxyCountryCode = (
Literal[
'us', # United States
'uk', # United Kingdom
'fr', # France
'it', # Italy
'jp', # Japan
'au', # Australia
'de', # Germany
'fi', # Finland
'ca', # Canada
'in', # India
]
| str
)
# Browser session timeout limits (in minutes)
MAX_FREE_USER_SESSION_TIMEOUT = 15 # Free users limited to 15 minutes
MAX_PAID_USER_SESSION_TIMEOUT = 240 # Paid users can go up to 4 hours
# Requests
class CreateBrowserRequest(BaseModel):
"""Request to create a cloud browser instance.
Args:
cloud_profile_id: The ID of the profile to use for the session
cloud_proxy_country_code: Country code for proxy location
cloud_timeout: The timeout for the session in minutes
"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
profile_id: UUID | str | None = Field(
default=None,
alias='cloud_profile_id',
description='The ID of the profile to use for the session. Can be a UUID or a string of UUID.',
title='Cloud Profile ID',
)
proxy_country_code: ProxyCountryCode | None = Field(
default=None,
alias='cloud_proxy_country_code',
description='Country code for proxy location.',
title='Cloud Proxy Country Code',
)
timeout: int | None = Field(
ge=1,
le=MAX_PAID_USER_SESSION_TIMEOUT,
default=None,
alias='cloud_timeout',
description=f'The timeout for the session in minutes. Free users are limited to {MAX_FREE_USER_SESSION_TIMEOUT} minutes, paid users can use up to {MAX_PAID_USER_SESSION_TIMEOUT} minutes ({MAX_PAID_USER_SESSION_TIMEOUT // 60} hours).',
title='Cloud Timeout',
)
enable_recording: bool = Field(
default=False,
alias='enableRecording',
description='Enable session recording for playback in the cloud dashboard.',
title='Enable Recording',
)
CloudBrowserParams = CreateBrowserRequest # alias for easier readability
# Responses
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)
# Errors
class CloudBrowserError(Exception):
"""Exception raised when cloud browser operations fail."""
pass
class CloudBrowserAuthError(CloudBrowserError):
"""Exception raised when cloud browser authentication fails."""
pass
File diff suppressed because one or more lines are too long
+667
View File
@@ -0,0 +1,667 @@
"""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(
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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_NavigateToUrlEvent', 30.0)) # seconds
class ClickElementEvent(ElementSelectedEvent[dict[str, Any] | None]):
"""Click an element."""
node: 'EnhancedDOMTreeNode'
button: Literal['left', 'right', 'middle'] = 'left'
# click_count: int = 1 # TODO
# expect_download: bool = False # moved to downloads_watchdog.py
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ClickElementEvent', 15.0)) # seconds
class ClickCoordinateEvent(BaseEvent[dict]):
"""Click at specific coordinates."""
coordinate_x: int
coordinate_y: int
button: Literal['left', 'right', 'middle'] = 'left'
force: bool = False # If True, skip safety checks (file input, print, select)
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ClickCoordinateEvent', 15.0)) # seconds
class TypeTextEvent(ElementSelectedEvent[dict | None]):
"""Type text into an element."""
node: 'EnhancedDOMTreeNode'
text: str
clear: 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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_TypeTextEvent', 60.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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_SwitchTabEvent', 10.0)) # seconds
class CloseTabEvent(BaseEvent[None]):
"""Close a tab."""
target_id: TargetID
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_ScreenshotEvent', 15.0)) # seconds
class BrowserStateRequestEvent(BaseEvent[BrowserStateSummary]):
"""Request current browser state."""
include_dom: bool = True
include_screenshot: bool = True
include_recent_events: bool = False
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_GoBackEvent', 15.0)) # seconds
class GoForwardEvent(BaseEvent[None]):
"""Navigate forward in browser history."""
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_GoForwardEvent', 15.0)) # seconds
class RefreshEvent(BaseEvent[None]):
"""Refresh/reload the current page."""
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_SendKeysEvent', 60.0)) # seconds
class UploadFileEvent(ElementSelectedEvent[None]):
"""Upload a file to an element."""
node: 'EnhancedDOMTreeNode'
file_path: str
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(
default_factory=lambda: _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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserStartEvent', 30.0)) # seconds
class BrowserStopEvent(BaseEvent):
"""Stop/disconnect from browser."""
force: bool = False
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserLaunchEvent', 30.0)) # seconds
class BrowserKillEvent(BaseEvent):
"""Kill local browser subprocess."""
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserConnectedEvent', 30.0)) # seconds
class BrowserStoppedEvent(BaseEvent):
"""Browser has stopped/disconnected."""
reason: str | None = None
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserStoppedEvent', 30.0)) # seconds
class TabCreatedEvent(BaseEvent):
"""A new tab was created."""
target_id: TargetID
url: str
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_TabClosedEvent', 3.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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_AgentFocusChangedEvent', 10.0)) # seconds
class TargetCrashedEvent(BaseEvent):
"""A target has crashed."""
target_id: TargetID
error: str
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_TargetCrashedEvent', 10.0)) # seconds
class NavigationStartedEvent(BaseEvent):
"""Navigation started."""
target_id: TargetID
url: str
event_timeout: float | None = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserErrorEvent', 30.0)) # seconds
class BrowserReconnectingEvent(BaseEvent):
"""WebSocket reconnection attempt is starting."""
cdp_url: str
attempt: int
max_attempts: int
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserReconnectingEvent', 30.0)) # seconds
class BrowserReconnectedEvent(BaseEvent):
"""WebSocket reconnection succeeded."""
cdp_url: str
attempt: int
downtime_seconds: float
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserReconnectedEvent', 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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _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 = Field(default_factory=lambda: _get_timeout('TIMEOUT_StorageStateLoadedEvent', 30.0)) # seconds
# ============================================================================
# File Download Events
# ============================================================================
class DownloadStartedEvent(BaseEvent):
"""A file download has started (CDP downloadWillBegin received)."""
guid: str # CDP download GUID to correlate with FileDownloadedEvent
url: str
suggested_filename: str
auto_download: bool = False # Whether this was triggered automatically
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_DownloadStartedEvent', 5.0)) # seconds
class DownloadProgressEvent(BaseEvent):
"""A file download progress update (CDP downloadProgress received)."""
guid: str # CDP download GUID to correlate with other download events
received_bytes: int
total_bytes: int # 0 if unknown
state: str # 'inProgress', 'completed', or 'canceled'
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_DownloadProgressEvent', 5.0)) # seconds
class FileDownloadedEvent(BaseEvent):
"""A file has been downloaded."""
guid: str | None = None # CDP download GUID to correlate with DownloadStartedEvent
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 = Field(default_factory=lambda: _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
# ============================================================================
# Captcha Solver Events
# ============================================================================
class CaptchaSolverStartedEvent(BaseEvent):
"""Captcha solving started by the browser proxy.
Emitted when the browser proxy detects a CAPTCHA and begins solving it.
The agent should wait for a corresponding CaptchaSolverFinishedEvent before proceeding.
"""
target_id: TargetID
vendor: str # e.g. 'cloudflare', 'recaptcha', 'hcaptcha', 'datadome', 'perimeterx', 'geetest'
url: str
started_at: int # Unix millis
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_CaptchaSolverStartedEvent', 5.0))
class CaptchaSolverFinishedEvent(BaseEvent):
"""Captcha solving finished by the browser proxy.
Emitted when the browser proxy finishes solving a CAPTCHA (successfully or not).
"""
target_id: TargetID
vendor: str
url: str
duration_ms: int
finished_at: int # Unix millis
success: bool # Whether the captcha was solved successfully
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_CaptchaSolverFinishedEvent', 5.0))
# 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
+548
View File
@@ -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(backend_node_id: int | None) -> bool:
"""Determine if index overlay should be shown."""
return backend_node_id 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
backend_node_id = getattr(element, 'backend_node_id', None)
index_text = None
if backend_node_id 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(backend_node_id)
else:
# Always show ID when filter is disabled
index_text = str(backend_node_id)
# 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
+918
View File
@@ -0,0 +1,918 @@
"""Event-driven CDP session management.
Manages CDP sessions by listening to Target.attachedToTarget and Target.detachedFromTarget
events, ensuring the session pool always reflects the current browser state.
"""
import asyncio
from collections import deque
from typing import TYPE_CHECKING, Any
from cdp_use.cdp.target import AttachedToTargetEvent, DetachedFromTargetEvent, SessionID, TargetID
from browser_use.utils import create_task_with_error_handling
if TYPE_CHECKING:
from browser_use.browser.session import BrowserSession, CDPSession, Target
class SessionManager:
"""Event-driven CDP session manager.
Automatically synchronizes the CDP session pool with browser state via CDP events.
Key features:
- Sessions added/removed automatically via Target attach/detach events
- Multiple sessions can attach to the same target
- Targets only removed when ALL sessions detach
- No stale sessions - pool always reflects browser reality
SessionManager is the SINGLE SOURCE OF TRUTH for all targets and sessions.
"""
def __init__(self, browser_session: 'BrowserSession'):
self.browser_session = browser_session
self.logger = browser_session.logger
# All targets (entities: pages, iframes, workers)
self._targets: dict[TargetID, 'Target'] = {}
# All sessions (communication channels)
self._sessions: dict[SessionID, 'CDPSession'] = {}
# Mapping: target -> sessions attached to it
self._target_sessions: dict[TargetID, set[SessionID]] = {}
# Reverse mapping: session -> target it belongs to
self._session_to_target: dict[SessionID, TargetID] = {}
# Page lifecycle events per target, fed by ONE global Page.lifecycleEvent handler
# registered in start_monitoring(). cdp-use's event registry is single-slot per
# CDP method, so per-session handler registrations would replace each other and
# leave every tab but the most recently attached one without lifecycle events.
self._lifecycle_events: dict[TargetID, deque[dict[str, Any]]] = {}
self._lock = asyncio.Lock()
self._recovery_lock = asyncio.Lock()
# Focus recovery coordination - event-driven instead of polling
self._recovery_in_progress: bool = False
self._recovery_complete_event: asyncio.Event | None = None
self._recovery_task: asyncio.Task | None = None
async def start_monitoring(self) -> None:
"""Start monitoring Target attach/detach events.
Registers CDP event handlers to keep the session pool synchronized with browser state.
Also discovers and initializes all existing targets on startup.
"""
if not self.browser_session._cdp_client_root:
raise RuntimeError('CDP client not initialized')
# Capture cdp_client_root in closure to avoid type errors
cdp_client = self.browser_session._cdp_client_root
# Enable target discovery to receive targetInfoChanged events automatically
# This eliminates the need for getTargetInfo() polling calls
await cdp_client.send.Target.setDiscoverTargets(
params={'discover': True, 'filter': [{'type': 'page'}, {'type': 'iframe'}]}
)
# Register synchronous event handlers (CDP requirement)
def on_attached(event: AttachedToTargetEvent, session_id: SessionID | None = None):
# _handle_target_attached() handles:
# - setAutoAttach for children
# - Create CDPSession
# - Enable monitoring (for pages/tabs)
# - Add to pool
create_task_with_error_handling(
self._handle_target_attached(event),
name='handle_target_attached',
logger_instance=self.logger,
suppress_exceptions=True,
)
def on_detached(event: DetachedFromTargetEvent, session_id: SessionID | None = None):
create_task_with_error_handling(
self._handle_target_detached(event),
name='handle_target_detached',
logger_instance=self.logger,
suppress_exceptions=True,
)
def on_target_info_changed(event, session_id: SessionID | None = None):
# Update session info from targetInfoChanged events (no polling needed!)
create_task_with_error_handling(
self._handle_target_info_changed(event),
name='handle_target_info_changed',
logger_instance=self.logger,
suppress_exceptions=True,
)
def on_lifecycle_event(event, session_id: SessionID | None = None):
# ONE global handler for all targets: route by session_id -> target_id.
# Registering per-session closures instead would clobber each other in
# cdp-use's single-slot registry (one handler per CDP method).
if not session_id:
return
target_id = self.get_target_id_from_session_id(session_id)
if not target_id:
return
self.get_lifecycle_events(target_id).append(
{
'name': event.get('name', 'unknown'),
'loaderId': event.get('loaderId'),
'timestamp': asyncio.get_event_loop().time(),
}
)
cdp_client.register.Target.attachedToTarget(on_attached)
cdp_client.register.Target.detachedFromTarget(on_detached)
cdp_client.register.Target.targetInfoChanged(on_target_info_changed)
cdp_client.register.Page.lifecycleEvent(on_lifecycle_event)
self.logger.debug('[SessionManager] Event monitoring started')
# Discover and initialize ALL existing targets
await self._initialize_existing_targets()
def get_lifecycle_events(self, target_id: TargetID) -> 'deque[dict[str, Any]]':
"""Get (creating if needed) the lifecycle event buffer for a target."""
events = self._lifecycle_events.get(target_id)
if events is None:
events = deque(maxlen=50)
self._lifecycle_events[target_id] = events
return events
def _get_session_for_target(self, target_id: TargetID) -> 'CDPSession | None':
"""Internal: Get ANY valid session for a target (picks first available).
⚠️ INTERNAL API - Use browser_session.get_or_create_cdp_session() instead!
This method has no validation, no focus management, no recovery.
Args:
target_id: Target ID to get session for
Returns:
CDPSession if exists, None if target has detached
"""
session_ids = self._target_sessions.get(target_id, set())
if not session_ids:
# Check if this is the focused target - indicates stale focus that needs cleanup
if self.browser_session.agent_focus_target_id == target_id:
self.logger.warning(
f'[SessionManager] ⚠️ Attempted to get session for stale focused target {target_id[:8]}... '
f'Clearing stale focus and triggering recovery.'
)
# Clear stale focus immediately (defense in depth)
self.browser_session.agent_focus_target_id = None
# Trigger recovery if not already in progress
if not self._recovery_in_progress:
self.logger.warning('[SessionManager] Recovery was not in progress! Triggering now.')
self._recovery_task = create_task_with_error_handling(
self._recover_agent_focus(target_id),
name='recover_agent_focus_from_stale_get',
logger_instance=self.logger,
suppress_exceptions=False,
)
return None
return self._sessions.get(next(iter(session_ids)))
def get_all_page_targets(self) -> list:
"""Get all page/tab targets using owned data.
Returns:
List of Target objects for all page/tab targets
"""
page_targets = []
for target in self._targets.values():
if target.target_type in ('page', 'tab'):
page_targets.append(target)
return page_targets
async def validate_session(self, target_id: TargetID) -> bool:
"""Check if a target still has active sessions.
Args:
target_id: Target ID to validate
Returns:
True if target has active sessions, False if it should be removed
"""
if target_id not in self._target_sessions:
return False
return len(self._target_sessions[target_id]) > 0
async def clear(self) -> None:
"""Clear all owned data structures for cleanup."""
async with self._lock:
# Clear owned data (single source of truth)
self._targets.clear()
self._sessions.clear()
self._target_sessions.clear()
self._session_to_target.clear()
self.logger.info('[SessionManager] Cleared all owned data (targets, sessions, mappings)')
async def is_target_valid(self, target_id: TargetID) -> bool:
"""Check if a target is still valid and has active sessions.
Args:
target_id: Target ID to validate
Returns:
True if target is valid and has active sessions, False otherwise
"""
if target_id not in self._target_sessions:
return False
return len(self._target_sessions[target_id]) > 0
def get_target_id_from_session_id(self, session_id: SessionID) -> TargetID | None:
"""Look up which target a session belongs to.
Args:
session_id: The session ID to look up
Returns:
Target ID if found, None otherwise
"""
return self._session_to_target.get(session_id)
def get_target(self, target_id: TargetID) -> 'Target | None':
"""Get target from owned data.
Args:
target_id: Target ID to get
Returns:
Target object if found, None otherwise
"""
return self._targets.get(target_id)
def get_all_targets(self) -> dict[TargetID, 'Target']:
"""Get all targets (read-only access to owned data).
Returns:
Dict mapping target_id to Target objects
"""
return self._targets
def get_all_target_ids(self) -> list[TargetID]:
"""Get all target IDs from owned data.
Returns:
List of all target IDs
"""
return list(self._targets.keys())
def get_all_sessions(self) -> dict[SessionID, 'CDPSession']:
"""Get all sessions (read-only access to owned data).
Returns:
Dict mapping session_id to CDPSession objects
"""
return self._sessions
def get_session(self, session_id: SessionID) -> 'CDPSession | None':
"""Get session from owned data.
Args:
session_id: Session ID to get
Returns:
CDPSession object if found, None otherwise
"""
return self._sessions.get(session_id)
def get_all_sessions_for_target(self, target_id: TargetID) -> list['CDPSession']:
"""Get ALL sessions attached to a target from owned data.
Args:
target_id: Target ID to get sessions for
Returns:
List of all CDPSession objects for this target
"""
session_ids = self._target_sessions.get(target_id, set())
return [self._sessions[sid] for sid in session_ids if sid in self._sessions]
def get_target_sessions_mapping(self) -> dict[TargetID, set[SessionID]]:
"""Get target->sessions mapping (read-only access).
Returns:
Dict mapping target_id to set of session_ids
"""
return self._target_sessions
def get_focused_target(self) -> 'Target | None':
"""Get the target that currently has agent focus.
Convenience method that uses browser_session.agent_focus_target_id.
Returns:
Target object if agent has focus, None otherwise
"""
if not self.browser_session.agent_focus_target_id:
return None
return self.get_target(self.browser_session.agent_focus_target_id)
async def ensure_valid_focus(self, timeout: float = 3.0) -> bool:
"""Ensure agent_focus_target_id points to a valid, attached CDP session.
If the focus target is stale (detached), this method waits for automatic recovery.
Uses event-driven coordination instead of polling for efficiency.
Args:
timeout: Maximum time to wait for recovery in seconds (default: 3.0)
Returns:
True if focus is valid or successfully recovered, False if no focus or recovery failed
"""
if not self.browser_session.agent_focus_target_id:
# No focus at all - might be initial state or complete failure
if self._recovery_in_progress and self._recovery_complete_event:
# Recovery is happening, wait for it
try:
await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=timeout)
# Check again after recovery - simple existence check
focus_id = self.browser_session.agent_focus_target_id
return bool(focus_id and self._get_session_for_target(focus_id))
except TimeoutError:
self.logger.error(f'[SessionManager] ❌ Timed out waiting for recovery after {timeout}s')
return False
return False
# Simple existence check - does the focused target have a session?
cdp_session = self._get_session_for_target(self.browser_session.agent_focus_target_id)
if cdp_session:
# Session exists - validate it's still active
is_valid = await self.validate_session(self.browser_session.agent_focus_target_id)
if is_valid:
return True
# Focus is stale - wait for recovery using event instead of polling
stale_target_id = self.browser_session.agent_focus_target_id
self.logger.warning(
f'[SessionManager] ⚠️ Stale agent_focus detected (target {stale_target_id[:8] if stale_target_id else "None"}... detached), '
f'waiting for recovery...'
)
# Check if recovery is already in progress
if not self._recovery_in_progress:
self.logger.warning(
'[SessionManager] ⚠️ Recovery not in progress for stale focus! '
'This indicates a bug - recovery should have been triggered.'
)
return False
# Wait for recovery complete event (event-driven, not polling!)
if self._recovery_complete_event:
try:
start_time = asyncio.get_event_loop().time()
await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=timeout)
elapsed = asyncio.get_event_loop().time() - start_time
# Verify recovery succeeded - simple existence check
focus_id = self.browser_session.agent_focus_target_id
if focus_id and self._get_session_for_target(focus_id):
self.logger.info(
f'[SessionManager] ✅ Agent focus recovered to {self.browser_session.agent_focus_target_id[:8]}... '
f'after {elapsed * 1000:.0f}ms'
)
return True
else:
self.logger.error(
f'[SessionManager] ❌ Recovery completed but focus still invalid after {elapsed * 1000:.0f}ms'
)
return False
except TimeoutError:
self.logger.error(
f'[SessionManager] ❌ Recovery timed out after {timeout}s '
f'(was: {stale_target_id[:8] if stale_target_id else "None"}..., '
f'now: {self.browser_session.agent_focus_target_id[:8] if self.browser_session.agent_focus_target_id else "None"})'
)
return False
else:
self.logger.error('[SessionManager] ❌ Recovery event not initialized')
return False
async def _handle_target_attached(self, event: AttachedToTargetEvent) -> None:
"""Handle Target.attachedToTarget event.
Called automatically by Chrome when a new target/session is created.
This is the ONLY place where sessions are added to the pool.
"""
target_id = event['targetInfo']['targetId']
session_id = event['sessionId']
target_type = event['targetInfo']['type']
target_info = event['targetInfo']
waiting_for_debugger = event.get('waitingForDebugger', False)
self.logger.debug(
f'[SessionManager] Target attached: {target_id[:8]}... (session={session_id[:8]}..., '
f'type={target_type}, waitingForDebugger={waiting_for_debugger})'
)
# Defensive check: browser may be shutting down and _cdp_client_root could be None
if self.browser_session._cdp_client_root is None:
self.logger.debug(
f'[SessionManager] Skipping target attach for {target_id[:8]}... - browser shutting down (no CDP client)'
)
return
# Enable auto-attach for this session's children (do this FIRST, outside lock)
try:
await self.browser_session._cdp_client_root.send.Target.setAutoAttach(
params={'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True}, session_id=session_id
)
except Exception as e:
error_str = str(e)
# Expected for short-lived targets (workers, temp iframes) that detach before this executes
if '-32001' not in error_str and 'Session with given id not found' not in error_str:
self.logger.debug(f'[SessionManager] Auto-attach failed for {target_type}: {e}')
from browser_use.browser.session import Target
async with self._lock:
# Track this session for the target
if target_id not in self._target_sessions:
self._target_sessions[target_id] = set()
self._target_sessions[target_id].add(session_id)
self._session_to_target[session_id] = target_id
# Create or update Target inside the same lock so that get_target() is never
# called in the window between _target_sessions being set and _targets being set.
if target_id not in self._targets:
target = Target(
target_id=target_id,
target_type=target_type,
url=target_info.get('url', 'about:blank'),
title=target_info.get('title', 'Unknown title'),
)
self._targets[target_id] = target
self.logger.debug(f'[SessionManager] Created target {target_id[:8]}... (type={target_type})')
else:
# Update existing target info
existing_target = self._targets[target_id]
existing_target.url = target_info.get('url', existing_target.url)
existing_target.title = target_info.get('title', existing_target.title)
# Create CDPSession (communication channel)
from browser_use.browser.session import CDPSession
assert self.browser_session._cdp_client_root is not None, 'Root CDP client required'
cdp_session = CDPSession(
cdp_client=self.browser_session._cdp_client_root,
target_id=target_id,
session_id=session_id,
)
# Add to sessions dict
self._sessions[session_id] = cdp_session
# If proxy auth is configured, enable Fetch auth handling on this session
# Avoids overwriting Target.attachedToTarget handlers elsewhere
try:
proxy_cfg = self.browser_session.browser_profile.proxy
username = proxy_cfg.username if proxy_cfg else None
password = proxy_cfg.password if proxy_cfg else None
if username and password:
await cdp_session.cdp_client.send.Fetch.enable(
params={'handleAuthRequests': True},
session_id=cdp_session.session_id,
)
self.logger.debug(f'[SessionManager] Fetch.enable(handleAuthRequests=True) on session {session_id[:8]}...')
except Exception as e:
self.logger.debug(f'[SessionManager] Fetch.enable on attached session failed: {type(e).__name__}: {e}')
self.logger.debug(
f'[SessionManager] Created session {session_id[:8]}... for target {target_id[:8]}... '
f'(total sessions: {len(self._sessions)})'
)
# Enable lifecycle events and network monitoring for page targets
if target_type in ('page', 'tab'):
await self._enable_page_monitoring(cdp_session)
# Resume execution if waiting for debugger
if waiting_for_debugger:
try:
assert self.browser_session._cdp_client_root is not None
await self.browser_session._cdp_client_root.send.Runtime.runIfWaitingForDebugger(session_id=session_id)
except Exception as e:
self.logger.warning(f'[SessionManager] Failed to resume execution: {e}')
async def _handle_target_info_changed(self, event: dict) -> None:
"""Handle Target.targetInfoChanged event.
Updates target title/URL without polling getTargetInfo().
Chrome fires this automatically when title or URL changes.
"""
target_info = event.get('targetInfo', {})
target_id = target_info.get('targetId')
if not target_id:
return
async with self._lock:
# Update target if it exists (source of truth for url/title)
if target_id in self._targets:
target = self._targets[target_id]
target.title = target_info.get('title', target.title)
target.url = target_info.get('url', target.url)
async def _handle_target_detached(self, event: DetachedFromTargetEvent) -> None:
"""Handle Target.detachedFromTarget event.
Called automatically by Chrome when a target/session is destroyed.
This is the ONLY place where sessions are removed from the pool.
"""
session_id = event['sessionId']
target_id = event.get('targetId') # May be empty
# If targetId not in event, look it up via session mapping
if not target_id:
async with self._lock:
target_id = self._session_to_target.get(session_id)
if not target_id:
self.logger.warning(f'[SessionManager] Session detached but target unknown (session={session_id[:8]}...)')
return
agent_focus_lost = False
target_fully_removed = False
target_type = None
async with self._lock:
# Remove this session from target's session set
if target_id in self._target_sessions:
self._target_sessions[target_id].discard(session_id)
remaining_sessions = len(self._target_sessions[target_id])
self.logger.debug(
f'[SessionManager] Session detached: target={target_id[:8]}... '
f'session={session_id[:8]}... (remaining={remaining_sessions})'
)
# Only remove target when NO sessions remain
if remaining_sessions == 0:
self.logger.debug(f'[SessionManager] No sessions remain for target {target_id[:8]}..., removing target')
target_fully_removed = True
# Check if agent_focus points to this target
agent_focus_lost = self.browser_session.agent_focus_target_id == target_id
# Immediately clear stale focus to prevent operations on detached target
if agent_focus_lost:
self.logger.debug(
f'[SessionManager] Clearing stale agent_focus_target_id {target_id[:8]}... '
f'to prevent operations on detached target'
)
self.browser_session.agent_focus_target_id = None
# Get target type before removing (needed for TabClosedEvent dispatch)
target = self._targets.get(target_id)
target_type = target.target_type if target else None
# Remove target (entity) from owned data
if target_id in self._targets:
self._targets.pop(target_id)
self.logger.debug(
f'[SessionManager] Removed target {target_id[:8]}... (remaining targets: {len(self._targets)})'
)
# Clean up tracking
del self._target_sessions[target_id]
self._lifecycle_events.pop(target_id, None)
else:
# Target not tracked - already removed or never attached
self.logger.debug(
f'[SessionManager] Session detached from untracked target: target={target_id[:8]}... '
f'session={session_id[:8]}... (target was already removed or attach event was missed)'
)
# Remove session from owned sessions dict
if session_id in self._sessions:
self._sessions.pop(session_id)
self.logger.debug(
f'[SessionManager] Removed session {session_id[:8]}... (remaining sessions: {len(self._sessions)})'
)
# Remove from reverse mapping
if session_id in self._session_to_target:
del self._session_to_target[session_id]
# Dispatch TabClosedEvent only for page/tab targets that are fully removed (not iframes/workers or partial detaches)
if target_fully_removed:
if target_type in ('page', 'tab'):
from browser_use.browser.events import TabClosedEvent
self.browser_session.event_bus.dispatch(TabClosedEvent(target_id=target_id))
self.logger.debug(f'[SessionManager] Dispatched TabClosedEvent for page target {target_id[:8]}...')
elif target_type:
self.logger.debug(
f'[SessionManager] Target {target_id[:8]}... fully removed (type={target_type}) - not dispatching TabClosedEvent'
)
# Auto-recover agent_focus outside the lock to avoid blocking other operations
if agent_focus_lost:
# Create recovery task instead of awaiting directly - allows concurrent operations to wait on same recovery
if not self._recovery_in_progress:
self._recovery_task = create_task_with_error_handling(
self._recover_agent_focus(target_id),
name='recover_agent_focus',
logger_instance=self.logger,
suppress_exceptions=False,
)
async def _recover_agent_focus(self, crashed_target_id: TargetID) -> None:
"""Auto-recover agent_focus when the focused target crashes/detaches.
Uses recovery lock to prevent concurrent recovery attempts from creating multiple emergency tabs.
Coordinates with ensure_valid_focus() via events for efficient waiting.
Args:
crashed_target_id: The target ID that was lost
"""
try:
# Prevent concurrent recovery attempts
async with self._recovery_lock:
# Set recovery state INSIDE lock to prevent race conditions
if self._recovery_in_progress:
self.logger.debug('[SessionManager] Recovery already in progress, waiting for it to complete')
# Wait for ongoing recovery instead of starting a new one
if self._recovery_complete_event:
try:
await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=5.0)
except TimeoutError:
self.logger.error('[SessionManager] Timed out waiting for ongoing recovery')
return
# Set recovery state
self._recovery_in_progress = True
self._recovery_complete_event = asyncio.Event()
if self.browser_session._cdp_client_root is None:
self.logger.debug('[SessionManager] Skipping focus recovery - browser shutting down (no CDP client)')
return
# Check if another recovery already fixed agent_focus
if self.browser_session.agent_focus_target_id and self.browser_session.agent_focus_target_id != crashed_target_id:
self.logger.debug(
f'[SessionManager] Agent focus already recovered by concurrent operation '
f'(now: {self.browser_session.agent_focus_target_id[:8]}...), skipping recovery'
)
return
# Note: agent_focus_target_id may already be None (cleared in _handle_target_detached)
current_focus_desc = (
f'{self.browser_session.agent_focus_target_id[:8]}...'
if self.browser_session.agent_focus_target_id
else 'None (already cleared)'
)
self.logger.warning(
f'[SessionManager] Agent focus target {crashed_target_id[:8]}... detached! '
f'Current focus: {current_focus_desc}. Auto-recovering by switching to another target...'
)
# Perform recovery (outside lock to allow concurrent operations)
# Try to find another valid page target
page_targets = self.get_all_page_targets()
new_target_id = None
is_existing_tab = False
if page_targets:
# Switch to most recent page that's not the crashed one
new_target_id = page_targets[-1].target_id
is_existing_tab = True
self.logger.info(f'[SessionManager] Switching agent_focus to existing tab {new_target_id[:8]}...')
else:
# No pages exist - create a new one
self.logger.warning('[SessionManager] No tabs remain! Creating new tab for agent...')
new_target_id = await self.browser_session._cdp_create_new_page('about:blank')
self.logger.info(f'[SessionManager] Created new tab {new_target_id[:8]}... for agent')
# Dispatch TabCreatedEvent so watchdogs can initialize
from browser_use.browser.events import TabCreatedEvent
self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=new_target_id))
# Wait for CDP attach event to create session
# Note: This polling is necessary - waiting for external Chrome CDP event
# _handle_target_attached will add session to pool when Chrome fires attachedToTarget
new_session = None
for attempt in range(20): # Wait up to 2 seconds
await asyncio.sleep(0.1)
new_session = self._get_session_for_target(new_target_id)
if new_session:
break
if new_session:
self.browser_session.agent_focus_target_id = new_target_id
self.logger.info(f'[SessionManager] ✅ Agent focus recovered: {new_target_id[:8]}...')
# Visually activate the tab in browser (only for existing tabs)
if is_existing_tab:
try:
assert self.browser_session._cdp_client_root is not None
await self.browser_session._cdp_client_root.send.Target.activateTarget(params={'targetId': new_target_id})
self.logger.debug(f'[SessionManager] Activated tab {new_target_id[:8]}... in browser UI')
except Exception as e:
self.logger.debug(f'[SessionManager] Failed to activate tab visually: {e}')
# Get target to access url (from owned data)
target = self.get_target(new_target_id)
target_url = target.url if target else 'about:blank'
# Dispatch focus changed event
from browser_use.browser.events import AgentFocusChangedEvent
self.browser_session.event_bus.dispatch(AgentFocusChangedEvent(target_id=new_target_id, url=target_url))
return
# Recovery failed - create emergency fallback tab
self.logger.error(
f'[SessionManager] ❌ Failed to get session for {new_target_id[:8]}... after 2s, creating emergency fallback tab'
)
fallback_target_id = await self.browser_session._cdp_create_new_page('about:blank')
self.logger.warning(f'[SessionManager] Created emergency fallback tab {fallback_target_id[:8]}...')
# Try one more time with fallback
# Note: This polling is necessary - waiting for external Chrome CDP event
for _ in range(20):
await asyncio.sleep(0.1)
fallback_session = self._get_session_for_target(fallback_target_id)
if fallback_session:
self.browser_session.agent_focus_target_id = fallback_target_id
self.logger.warning(f'[SessionManager] ⚠️ Agent focus set to emergency fallback: {fallback_target_id[:8]}...')
from browser_use.browser.events import AgentFocusChangedEvent, TabCreatedEvent
self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=fallback_target_id))
self.browser_session.event_bus.dispatch(
AgentFocusChangedEvent(target_id=fallback_target_id, url='about:blank')
)
return
# Complete failure - this should never happen
self.logger.critical(
'[SessionManager] 🚨 CRITICAL: Failed to recover agent_focus even with fallback! Agent may be in broken state.'
)
except Exception as e:
self.logger.error(f'[SessionManager] ❌ Error during agent_focus recovery: {type(e).__name__}: {e}')
finally:
# Always signal completion and reset recovery state
# This allows all waiting operations to proceed (success or failure)
if self._recovery_complete_event:
self._recovery_complete_event.set()
self._recovery_in_progress = False
self._recovery_task = None
self.logger.debug('[SessionManager] Recovery state reset')
async def _initialize_existing_targets(self) -> None:
"""Discover and initialize all existing targets at startup.
Attaches to each target and initializes it SYNCHRONOUSLY.
Chrome will also fire attachedToTarget events, but _handle_target_attached() is
idempotent (checks if target already in pool), so duplicate handling is safe.
This eliminates race conditions - monitoring is guaranteed ready before navigation.
"""
cdp_client = self.browser_session._cdp_client_root
assert cdp_client is not None
# Get all existing targets
targets_result = await cdp_client.send.Target.getTargets()
existing_targets = targets_result.get('targetInfos', [])
self.logger.debug(f'[SessionManager] Discovered {len(existing_targets)} existing targets')
# Track target IDs for verification
target_ids_to_wait_for = []
# Just attach to ALL existing targets - Chrome fires attachedToTarget events
# The on_attached handler (via create_task) does ALL the work
for target in existing_targets:
target_id = target['targetId']
target_type = target.get('type', 'unknown')
try:
# Just attach - event handler does everything
await cdp_client.send.Target.attachToTarget(params={'targetId': target_id, 'flatten': True})
target_ids_to_wait_for.append(target_id)
except Exception as e:
self.logger.debug(
f'[SessionManager] Failed to attach to existing target {target_id[:8]}... (type={target_type}): {e}'
)
# Wait for event handlers to complete their work (they run via create_task)
# Use event-driven approach instead of polling for better performance
ready_event = asyncio.Event()
async def check_all_ready():
"""Check if all sessions are ready and signal completion."""
while True:
ready_count = 0
for tid in target_ids_to_wait_for:
session = self._get_session_for_target(tid)
if session:
target = self._targets.get(tid)
target_type = target.target_type if target else 'unknown'
# For pages, verify monitoring is enabled
if target_type in ('page', 'tab'):
if hasattr(session, '_lifecycle_events') and session._lifecycle_events is not None:
ready_count += 1
else:
# Non-page targets don't need monitoring
ready_count += 1
if ready_count == len(target_ids_to_wait_for):
ready_event.set()
return
await asyncio.sleep(0.05)
# Start checking in background
check_task = create_task_with_error_handling(
check_all_ready(), name='check_all_targets_ready', logger_instance=self.logger
)
try:
# Wait for completion with timeout
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
except TimeoutError:
# Timeout - count what's ready
ready_count = 0
for tid in target_ids_to_wait_for:
session = self._get_session_for_target(tid)
if session:
target = self._targets.get(tid)
target_type = target.target_type if target else 'unknown'
# For pages, verify monitoring is enabled
if target_type in ('page', 'tab'):
if hasattr(session, '_lifecycle_events') and session._lifecycle_events is not None:
ready_count += 1
else:
# Non-page targets don't need monitoring
ready_count += 1
self.logger.warning(
f'[SessionManager] Initialization timeout after 2.0s: {ready_count}/{len(target_ids_to_wait_for)} sessions ready'
)
finally:
check_task.cancel()
try:
await check_task
except asyncio.CancelledError:
pass
async def _enable_page_monitoring(self, cdp_session: 'CDPSession') -> None:
"""Enable lifecycle events and network monitoring for a page target.
This is called once per page when it's created, avoiding handler accumulation.
Registers a SINGLE lifecycle handler per session that stores events for navigations to consume.
Args:
cdp_session: The CDP session to enable monitoring on
"""
try:
# Enable Page domain first (required for lifecycle events)
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
# Enable lifecycle events (load, DOMContentLoaded, networkIdle, etc.)
await cdp_session.cdp_client.send.Page.setLifecycleEventsEnabled(
params={'enabled': True}, session_id=cdp_session.session_id
)
# Enable network monitoring for networkIdle detection
await cdp_session.cdp_client.send.Network.enable(session_id=cdp_session.session_id)
# Event storage and the Page.lifecycleEvent handler live in SessionManager
# (one global handler registered in start_monitoring, routed by session_id):
# cdp-use's registry is single-slot per method, so a per-session registration
# here would replace the previous tab's handler and freeze its event buffer.
# Expose the shared per-target buffer on the session for readiness checks.
cdp_session._lifecycle_events = self.get_lifecycle_events(cdp_session.target_id)
except Exception as e:
# Don't fail - target might be short-lived or already detached
error_str = str(e)
if '-32001' in error_str or 'Session with given id not found' in error_str:
self.logger.debug(
f'[SessionManager] Target {cdp_session.target_id[:8]}... detached before monitoring could be enabled (normal for short-lived targets)'
)
else:
self.logger.warning(
f'[SessionManager] Failed to enable monitoring for target {cdp_session.target_id[:8]}...: {e}'
)
+141
View File
@@ -0,0 +1,141 @@
"""Video Recording Service for Browser Use Sessions."""
import base64
import io
import logging
import math
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 numpy as np # type: ignore[import-not-found]
from imageio.core.format import Format # type: ignore[import-not-found]
from PIL import Image
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)
# Use PIL to handle image processing in memory - much faster than spawning ffmpeg subprocess per frame
with Image.open(io.BytesIO(frame_bytes)) as img:
# 1. Resize if needed to target viewport size
if img.size != (self.size['width'], self.size['height']):
# Use BICUBIC as it's faster than LANCZOS and good enough for screen recordings
img = img.resize((self.size['width'], self.size['height']), Image.Resampling.BICUBIC)
# 2. Handle Padding (Macro block alignment for codecs)
# Check if padding is actually needed
if self.padded_size['width'] != self.size['width'] or self.padded_size['height'] != self.size['height']:
new_img = Image.new('RGB', (self.padded_size['width'], self.padded_size['height']), (0, 0, 0))
# Center the image
x_offset = (self.padded_size['width'] - self.size['width']) // 2
y_offset = (self.padded_size['height'] - self.size['height']) // 2
new_img.paste(img, (x_offset, y_offset))
img = new_img
# 3. Convert to numpy array for imageio
img_array = np.array(img)
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
+200
View File
@@ -0,0 +1,200 @@
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 NetworkRequest:
"""Information about a pending network request"""
url: str
method: str = 'GET'
loading_duration_ms: float = 0.0 # How long this request has been loading (ms since request started, max 10s)
resource_type: str | None = None # e.g., 'Document', 'Stylesheet', 'Image', 'Script', 'XHR', 'Fetch'
@dataclass
class PaginationButton:
"""Information about a pagination button detected on the page"""
button_type: str # 'next', 'prev', 'first', 'last', 'page_number'
backend_node_id: int # Backend node ID for clicking
text: str # Button text/label
selector: str # XPath or other selector to locate the element
is_disabled: bool = False # Whether the button appears disabled
@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
pending_network_requests: list[NetworkRequest] = field(default_factory=list) # Currently loading network requests
pagination_buttons: list[PaginationButton] = field(default_factory=list) # Detected pagination buttons
closed_popup_messages: list[str] = field(default_factory=list) # Messages from auto-closed JavaScript dialogs
@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:
parts = [self.message]
if self.details:
parts.append(f'({self.details})')
if self.while_handling_event:
parts.append(f'during: {self.while_handling_event}')
return ' '.join(parts)
class URLNotAllowedError(BrowserError):
"""Error raised when a URL is not allowed"""
+321
View File
@@ -0,0 +1,321 @@
"""Base watchdog class for browser monitoring components."""
import asyncio
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'
# Events that should always run even when CDP is disconnected (lifecycle management)
LIFECYCLE_EVENT_NAMES = frozenset(
{
'BrowserStartEvent',
'BrowserStopEvent',
'BrowserStoppedEvent',
'BrowserLaunchEvent',
'BrowserErrorEvent',
'BrowserKillEvent',
'BrowserReconnectingEvent',
'BrowserReconnectedEvent',
}
)
# 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):
# Circuit breaker: skip handler if CDP WebSocket is dead
# (prevents handlers from hanging on broken connections until timeout)
# Lifecycle events are exempt — they manage browser start/stop
if event.event_type not in LIFECYCLE_EVENT_NAMES and not browser_session.is_cdp_connected:
# If reconnection is in progress, wait for it instead of silently skipping
if browser_session.is_reconnecting:
wait_timeout = browser_session.RECONNECT_WAIT_TIMEOUT
browser_session.logger.debug(
f'🚌 [{watchdog_class_name}.{actual_handler.__name__}] ⏳ Waiting for reconnection ({wait_timeout}s)...'
)
try:
await asyncio.wait_for(browser_session._reconnect_event.wait(), timeout=wait_timeout)
except TimeoutError:
raise ConnectionError(
f'[{watchdog_class_name}.{actual_handler.__name__}] '
f'Reconnection wait timed out after {wait_timeout}s'
)
# After wait: check if reconnection actually succeeded
if not browser_session.is_cdp_connected:
raise ConnectionError(
f'[{watchdog_class_name}.{actual_handler.__name__}] Reconnection failed — CDP still not connected'
)
# Reconnection succeeded — fall through to execute handler normally
else:
# Not reconnecting — intentional stop, backward compat silent skip
browser_session.logger.debug(
f'🚌 [{watchdog_class_name}.{actual_handler.__name__}] ⚡ Skipped — CDP not connected'
)
return None
# 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'↲ triggered by on_{parent_event.event_type}#{parent_event.event_id[-4:]}'
if parent_event
else '👈 by Agent'
)
grandparent = (
(
f'↲ under {grandparent_event.event_type}#{grandparent_event.event_id[-4:]}'
if grandparent_event
else '👈 by Agent'
)
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'🚌 {watchdog_and_handler_str} ⏳ Starting... {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' ➡️ <{type(result).__name__}>'
parents_summary = f' {parent}'.replace('↲ triggered by ', '⤴ returned to ').replace(
'👈 by Agent', '👉 returned to Agent'
)
browser_session.logger.debug(
f'🚌 {watchdog_and_handler_str} Succeeded ({time_elapsed:.2f}s){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'🚌 {watchdog_and_handler_str} ❌ Failed ({time_elapsed:.2f}s): {type(e).__name__}: {e}'
)
# attempt to repair potentially crashed CDP session
try:
if browser_session.agent_focus_target_id:
# With event-driven sessions, Chrome will send detach/attach events
# SessionManager handles pool cleanup automatically
target_id_to_restore = browser_session.agent_focus_target_id
browser_session.logger.debug(
f'🚌 {watchdog_and_handler_str} ⚠️ Session error detected, waiting for CDP events to sync (target: {target_id_to_restore})'
)
# Wait for new attach event to restore the session
# This will raise ValueError if target doesn't re-attach
await browser_session.get_or_create_cdp_session(target_id=target_id_to_restore, focus=True)
else:
# Try to get any available session
await browser_session.get_or_create_cdp_session(target_id=None, 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'🚌 {watchdog_and_handler_str} ❌ Browser closed or CDP Connection disconnected by remote. {type(sub_error).__name__}: {sub_error}\n'
)
raise
else:
browser_session.logger.error(
f'🚌 {watchdog_and_handler_str} ❌ CDP connected but failed to re-create CDP session after error "{type(original_error).__name__}: {original_error}" in {actual_handler.__name__}({event.event_type}#{event.event_id[-4:]}): due to {type(sub_error).__name__}: {sub_error}\n'
)
# Always re-raise the original error with its traceback preserved
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)
@staticmethod
def detach_handler_from_session(browser_session: 'BrowserSession', event_class: type[BaseEvent[Any]], handler) -> None:
"""Detach a single event handler from a browser session."""
event_bus = browser_session.event_bus
# 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'
# Find and remove the handler by its unique name pattern
unique_handler_name = f'{watchdog_class_name}.{handler.__name__}'
existing_handlers = event_bus.handlers.get(event_class.__name__, [])
for existing_handler in existing_handlers[:]: # copy list to allow modification during iteration
if getattr(existing_handler, '__name__', '') == unique_handler_name:
existing_handlers.remove(existing_handler)
break
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__} garbage collection __del__(): {type(e)}: {e}')
@@ -0,0 +1,259 @@
"""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."""
# Don't create new tabs if browser is shutting down
if self._stopping:
return
# Don't attempt CDP operations if the WebSocket is dead — dispatching
# NavigateToUrlEvent on a broken connection will hang until timeout
if not self.browser_session.is_cdp_connected:
self.logger.debug('[AboutBlankWatchdog] CDP not connected, skipping tab recovery')
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:
if not self.browser_session.is_cdp_connected:
return
# 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,207 @@
"""Captcha solver watchdog — monitors captcha events from the browser proxy.
Listens for BrowserUse.captchaSolverStarted/Finished CDP events and exposes a
wait_if_captcha_solving() method that the agent step loop uses to block until
a captcha is resolved (with a configurable timeout).
NOTE: Only a single captcha solve is tracked at a time. If multiple captchas
overlap (e.g. rapid successive navigations), only the latest one is tracked and
earlier in-flight waits may return prematurely.
"""
import asyncio
from dataclasses import dataclass
from typing import Any, ClassVar, Literal
from bubus import BaseEvent
from cdp_use.cdp.browseruse.events import CaptchaSolverFinishedEvent as CDPCaptchaSolverFinishedEvent
from cdp_use.cdp.browseruse.events import CaptchaSolverStartedEvent as CDPCaptchaSolverStartedEvent
from pydantic import PrivateAttr
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserStoppedEvent,
CaptchaSolverFinishedEvent,
CaptchaSolverStartedEvent,
_get_timeout,
)
from browser_use.browser.watchdog_base import BaseWatchdog
CaptchaResultType = Literal['success', 'failed', 'timeout', 'unknown']
@dataclass
class CaptchaWaitResult:
"""Result returned by wait_if_captcha_solving() when the agent had to wait."""
waited: bool
vendor: str
url: str
duration_ms: int
result: CaptchaResultType
class CaptchaWatchdog(BaseWatchdog):
"""Monitors captcha solver events from the browser proxy.
When the proxy detects a CAPTCHA and starts solving it, a CDP event
``BrowserUse.captchaSolverStarted`` is sent over the WebSocket. This
watchdog catches that event and blocks the agent's step loop (via
``wait_if_captcha_solving``) until ``BrowserUse.captchaSolverFinished``
arrives or the configurable timeout expires.
"""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
BrowserStoppedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
CaptchaSolverStartedEvent,
CaptchaSolverFinishedEvent,
]
# --- private state ---
_captcha_solving: bool = PrivateAttr(default=False)
_captcha_solved_event: asyncio.Event = PrivateAttr(default_factory=asyncio.Event)
_captcha_info: dict[str, Any] = PrivateAttr(default_factory=dict)
_captcha_result: CaptchaResultType = PrivateAttr(default='unknown')
_captcha_duration_ms: int = PrivateAttr(default=0)
_cdp_handlers_registered: bool = PrivateAttr(default=False)
def model_post_init(self, __context: Any) -> None:
# Start in "not blocked" state so callers never wait when there is no captcha.
self._captcha_solved_event.set()
# ------------------------------------------------------------------
# Event handlers
# ------------------------------------------------------------------
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Register CDP event handlers for BrowserUse captcha solver events."""
if self._cdp_handlers_registered:
self.logger.debug('CaptchaWatchdog: CDP handlers already registered, skipping')
return
cdp_client = self.browser_session.cdp_client
def _on_captcha_started(event_data: CDPCaptchaSolverStartedEvent, session_id: str | None) -> None:
try:
self._captcha_solving = True
self._captcha_result = 'unknown'
self._captcha_duration_ms = 0
self._captcha_info = {
'vendor': event_data.get('vendor', 'unknown'),
'url': event_data.get('url', ''),
'targetId': event_data.get('targetId', ''),
'startedAt': event_data.get('startedAt', 0),
}
# Block any waiter
self._captcha_solved_event.clear()
vendor = self._captcha_info['vendor']
url = self._captcha_info['url']
self.logger.info(f'🔒 Captcha solving started: {vendor} on {url}')
self.event_bus.dispatch(
CaptchaSolverStartedEvent(
target_id=event_data.get('targetId', ''),
vendor=vendor,
url=url,
started_at=event_data.get('startedAt', 0),
)
)
except Exception:
self.logger.exception('Error handling captchaSolverStarted CDP event')
# Ensure consistent state: unblock any waiter
self._captcha_solving = False
self._captcha_solved_event.set()
def _on_captcha_finished(event_data: CDPCaptchaSolverFinishedEvent, session_id: str | None) -> None:
try:
success = event_data.get('success', False)
self._captcha_solving = False
self._captcha_duration_ms = event_data.get('durationMs', 0)
self._captcha_result = 'success' if success else 'failed'
vendor = event_data.get('vendor', self._captcha_info.get('vendor', 'unknown'))
url = event_data.get('url', self._captcha_info.get('url', ''))
duration_s = self._captcha_duration_ms / 1000
self.logger.info(f'🔓 Captcha solving finished: {self._captcha_result}{vendor} on {url} ({duration_s:.1f}s)')
# Unblock any waiter
self._captcha_solved_event.set()
self.event_bus.dispatch(
CaptchaSolverFinishedEvent(
target_id=event_data.get('targetId', ''),
vendor=vendor,
url=url,
duration_ms=self._captcha_duration_ms,
finished_at=event_data.get('finishedAt', 0),
success=success,
)
)
except Exception:
self.logger.exception('Error handling captchaSolverFinished CDP event')
# Ensure consistent state: unblock any waiter
self._captcha_solving = False
self._captcha_solved_event.set()
cdp_client.register.BrowserUse.captchaSolverStarted(_on_captcha_started)
cdp_client.register.BrowserUse.captchaSolverFinished(_on_captcha_finished)
self._cdp_handlers_registered = True
self.logger.debug('🔒 CaptchaWatchdog: registered CDP event handlers for BrowserUse captcha events')
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Clear captcha state when the browser disconnects so nothing hangs."""
self._captcha_solving = False
self._captcha_result = 'unknown'
self._captcha_duration_ms = 0
self._captcha_info = {}
self._captcha_solved_event.set()
self._cdp_handlers_registered = False
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def wait_if_captcha_solving(self, timeout: float | None = None) -> CaptchaWaitResult | None:
"""Wait if a captcha is currently being solved.
Returns:
``None`` if no captcha was in progress.
A ``CaptchaWaitResult`` with the outcome otherwise.
"""
if not self._captcha_solving:
return None
if timeout is None:
timeout = _get_timeout('TIMEOUT_CaptchaSolverWait', 120.0)
assert timeout is not None
vendor = self._captcha_info.get('vendor', 'unknown')
url = self._captcha_info.get('url', '')
self.logger.info(f'⏳ Waiting for {vendor} captcha to be solved on {url} (timeout={timeout}s)...')
try:
await asyncio.wait_for(self._captcha_solved_event.wait(), timeout=timeout)
return CaptchaWaitResult(
waited=True,
vendor=vendor,
url=url,
duration_ms=self._captcha_duration_ms,
result=self._captcha_result,
)
except TimeoutError:
# Timed out — unblock and report
self._captcha_solving = False
self._captcha_solved_event.set()
self.logger.warning(f'⏰ Captcha wait timed out after {timeout}s for {vendor} on {url}')
return CaptchaWaitResult(
waited=True,
vendor=vendor,
url=url,
duration_ms=int(timeout * 1000),
result='timeout',
)
@@ -0,0 +1,336 @@
"""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,
TabClosedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.utils import create_task_with_error_handling
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,
TabClosedEvent,
]
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
_targets_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track targets 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')
create_task_with_error_handling(
self._start_monitoring(), name='start_crash_monitoring', logger_instance=self.logger, suppress_exceptions=True
)
# 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_target_id is not None, 'No current target ID'
await self.attach_to_target(self.browser_session.agent_focus_target_id)
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
"""Clean up tracking when tab closes."""
# Remove target from listener tracking to prevent memory leak
if event.target_id in self._targets_with_listeners:
self._targets_with_listeners.discard(event.target_id)
self.logger.debug(f'[CrashWatchdog] Removed target {event.target_id[:8]}... from monitoring')
async def attach_to_target(self, target_id: TargetID) -> None:
"""Set up crash monitoring for a specific target using CDP."""
try:
# Check if we already have listeners for this target
if target_id in self._targets_with_listeners:
self.logger.debug(f'[CrashWatchdog] Event listeners already exist for target: {target_id[:8]}...')
return
# Create temporary session for monitoring without switching focus
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Register crash event handler
def on_target_crashed(event: TargetCrashedEvent, session_id: SessionID | None = None):
# Create and track the task
task = create_task_with_error_handling(
self._on_target_crash_cdp(target_id),
name='handle_target_crash',
logger_instance=self.logger,
suppress_exceptions=True,
)
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 target
self._targets_with_listeners.add(target_id)
target = self.browser_session.session_manager.get_target(target_id)
if target:
self.logger.debug(f'[CrashWatchdog] Added target to monitoring: {target.url}')
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."""
self.logger.debug(f'[CrashWatchdog] Target crashed: {target_id[:8]}..., waiting for detach event')
target = self.browser_session.session_manager.get_target(target_id)
is_agent_focus = (
target
and self.browser_session.agent_focus_target_id
and target.target_id == self.browser_session.agent_focus_target_id
)
if is_agent_focus:
self.logger.error(f'[CrashWatchdog] 💥 Agent focus tab crashed: {target.url} (SessionManager will auto-recover)')
# Emit browser error event
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='TargetCrash',
message=f'Target crashed: {target_id}',
details={
'url': target.url if target else None,
'target_id': target_id,
'was_agent_focus': is_agent_focus,
},
)
)
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 = create_task_with_error_handling(
self._monitoring_loop(), name='crash_monitoring_loop', logger_instance=self.logger, suppress_exceptions=True
)
# logger.debug('[CrashWatchdog] Monitoring loop created and started')
async def _stop_monitoring(self) -> None:
"""Stop the monitoring loop and clean up all tracking."""
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 all tracking
self._active_requests.clear()
self._targets_with_listeners.clear()
self._last_responsive_checks.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:
self.logger.debug(f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus_target_id}')
cdp_session = await self.browser_session.get_or_create_cdp_session()
for target in self.browser_session.session_manager.get_all_page_targets():
if self._is_new_tab_page(target.url) and target.url != 'about:blank':
self.logger.debug(f'[CrashWatchdog] Redirecting chrome://new-tab-page/ to about:blank {target.url}')
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target.target_id)
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_target_id}'
)
except Exception as e:
self.logger.error(
f'[CrashWatchdog] ❌ Crashed/unresponsive session detected for target {self.browser_session.agent_focus_target_id} '
f'error: {type(e).__name__}: {e} (Chrome will send detach event, SessionManager will auto-recover)'
)
# 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')
# Browser process crashed - SessionManager will clean up via detach events
# Just dispatch error event and stop monitoring
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='BrowserProcessCrashed',
message=f'Browser process {proc.pid} has crashed',
details={'pid': proc.pid, 'status': proc.status()},
)
)
self.logger.warning('[CrashWatchdog] Browser process dead - stopping health monitoring')
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/']
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,865 @@
"""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 create_task_with_error_handling, time_execution_async
if TYPE_CHECKING:
from browser_use.browser.views import BrowserStateSummary, NetworkRequest, PageInfo, PaginationButton
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
# Network tracking - maps request_id to (url, start_time, method, resource_type)
_pending_requests: dict[str, tuple[str, float, str, str | 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
async def _get_pending_network_requests(self) -> list['NetworkRequest']:
"""Get list of currently pending network requests.
Uses document.readyState and performance API to detect pending requests.
Filters out ads, tracking, and other noise.
Returns:
List of NetworkRequest objects representing currently loading resources
"""
from browser_use.browser.views import NetworkRequest
try:
# get_or_create_cdp_session() now handles focus validation automatically
cdp_session = await self.browser_session.get_or_create_cdp_session(focus=True)
# Use performance API to get pending requests
js_code = """
(function() {
const now = performance.now();
const resources = performance.getEntriesByType('resource');
const pending = [];
// Check document readyState
const docLoading = document.readyState !== 'complete';
// Common ad/tracking domains and patterns to filter out
const adDomains = [
// Standard ad/tracking networks
'doubleclick.net', 'googlesyndication.com', 'googletagmanager.com',
'facebook.net', 'analytics', 'ads', 'tracking', 'pixel',
'hotjar.com', 'clarity.ms', 'mixpanel.com', 'segment.com',
// Analytics platforms
'demdex.net', 'omtrdc.net', 'adobedtm.com', 'ensighten.com',
'newrelic.com', 'nr-data.net', 'google-analytics.com',
// Social media trackers
'connect.facebook.net', 'platform.twitter.com', 'platform.linkedin.com',
// CDN/image hosts (usually not critical for functionality)
'.cloudfront.net/image/', '.akamaized.net/image/',
// Common tracking paths
'/tracker/', '/collector/', '/beacon/', '/telemetry/', '/log/',
'/events/', '/eventBatch', '/track.', '/metrics/'
];
// Get resources that are still loading (responseEnd is 0)
let totalResourcesChecked = 0;
let filteredByResponseEnd = 0;
const allDomains = new Set();
for (const entry of resources) {
totalResourcesChecked++;
// Track all domains from recent resources (for logging)
try {
const hostname = new URL(entry.name).hostname;
if (hostname) allDomains.add(hostname);
} catch (e) {}
if (entry.responseEnd === 0) {
filteredByResponseEnd++;
const url = entry.name;
// Filter out ads and tracking
const isAd = adDomains.some(domain => url.includes(domain));
if (isAd) continue;
// Filter out data: URLs and very long URLs (often inline resources)
if (url.startsWith('data:') || url.length > 500) continue;
const loadingDuration = now - entry.startTime;
// Skip requests that have been loading for >10 seconds (likely stuck/polling)
if (loadingDuration > 10000) continue;
const resourceType = entry.initiatorType || 'unknown';
// Filter out non-critical resources (images, fonts, icons) if loading >3 seconds
const nonCriticalTypes = ['img', 'image', 'icon', 'font'];
if (nonCriticalTypes.includes(resourceType) && loadingDuration > 3000) continue;
// Filter out image URLs even if type is unknown
const isImageUrl = /\\.(jpg|jpeg|png|gif|webp|svg|ico)(\\?|$)/i.test(url);
if (isImageUrl && loadingDuration > 3000) continue;
pending.push({
url: url,
method: 'GET',
loading_duration_ms: Math.round(loadingDuration),
resource_type: resourceType
});
}
}
return {
pending_requests: pending,
document_loading: docLoading,
document_ready_state: document.readyState,
debug: {
total_resources: totalResourcesChecked,
with_response_end_zero: filteredByResponseEnd,
after_all_filters: pending.length,
all_domains: Array.from(allDomains)
}
};
})()
"""
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': js_code, 'returnByValue': True}, session_id=cdp_session.session_id
)
if result.get('result', {}).get('type') == 'object':
data = result['result'].get('value', {})
pending = data.get('pending_requests', [])
doc_state = data.get('document_ready_state', 'unknown')
doc_loading = data.get('document_loading', False)
debug_info = data.get('debug', {})
# Get all domains that had recent activity (from JS)
all_domains = debug_info.get('all_domains', [])
all_domains_str = ', '.join(sorted(all_domains)[:5]) if all_domains else 'none'
if len(all_domains) > 5:
all_domains_str += f' +{len(all_domains) - 5} more'
# Debug logging
self.logger.debug(
f'🔍 Network check: document.readyState={doc_state}, loading={doc_loading}, '
f'total_resources={debug_info.get("total_resources", 0)}, '
f'responseEnd=0: {debug_info.get("with_response_end_zero", 0)}, '
f'after_filters={len(pending)}, domains=[{all_domains_str}]'
)
# Convert to NetworkRequest objects
network_requests = []
for req in pending[:20]: # Limit to 20 to avoid overwhelming the context
network_requests.append(
NetworkRequest(
url=req['url'],
method=req.get('method', 'GET'),
loading_duration_ms=req.get('loading_duration_ms', 0.0),
resource_type=req.get('resource_type'),
)
)
return network_requests
except Exception as e:
self.logger.debug(f'Failed to get pending network requests: {e}')
return []
@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}')
# Get focused session for logging (validation already done by get_current_page_url)
if self.browser_session.agent_focus_target_id:
self.logger.debug(f'Current page URL: {page_url}, target_id: {self.browser_session.agent_focus_target_id}')
# 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')
# Check for pending network requests BEFORE waiting (so we can see what's loading)
# Timeout after 2s — on slow CI machines or heavy pages, this call can hang
# for 15s+ eating into the 30s BrowserStateRequestEvent budget.
pending_requests_before_wait = []
if not not_a_meaningful_website:
try:
pending_requests_before_wait = await asyncio.wait_for(self._get_pending_network_requests(), timeout=2.0)
if pending_requests_before_wait:
self.logger.debug(f'🔍 Found {len(pending_requests_before_wait)} pending requests before stability wait')
except TimeoutError:
self.logger.debug('Pending network request check timed out (2s), skipping')
except Exception as e:
self.logger.debug(f'Failed to get pending requests before wait: {e}')
pending_requests = pending_requests_before_wait
# 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:
if pending_requests_before_wait:
# Reduced from 1s to 0.3s for faster DOM builds while still allowing critical resources to load
await asyncio.sleep(0.3)
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,
pending_network_requests=[], # Empty page has no pending requests
pagination_buttons=[], # Empty page has no pagination
closed_popup_messages=self.browser_session._closed_popup_messages.copy(),
)
# 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 = create_task_with_error_handling(
self._build_dom_tree_without_highlights(previous_state),
name='build_dom_tree',
logger_instance=self.logger,
suppress_exceptions=True,
)
# 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 = create_task_with_error_handling(
self._capture_clean_screenshot(),
name='capture_screenshot',
logger_instance=self.logger,
suppress_exceptions=True,
)
# 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
# Add browser-side highlights for user visibility
if content and content.selector_map and self.browser_session.browser_profile.dom_highlight_elements:
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🎨 Adding browser-side highlights...')
await self.browser_session.add_highlights(content.selector_map)
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Added browser highlights for {len(content.selector_map)} elements'
)
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Browser 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
# Detect pagination buttons from the DOM
pagination_buttons_data = []
if content and content.selector_map:
pagination_buttons_data = self._detect_pagination_buttons(content.selector_map)
# 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,
pending_network_requests=pending_requests,
pagination_buttons=pagination_buttons_data,
closed_popup_messages=self.browser_session._closed_popup_messages.copy(),
)
# Cache the state
self.browser_session._cached_browser_state_summary = browser_state
# Cache viewport size for coordinate conversion (if llm_screenshot_size is enabled)
if page_info:
self.browser_session._original_viewport_size = (page_info.viewport_width, page_info.viewport_height)
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,
pending_network_requests=[], # Error state has no pending requests
pagination_buttons=[], # Error state has no pagination
closed_popup_messages=self.browser_session._closed_popup_messages.copy()
if hasattr(self, 'browser_session') and self.browser_session is not None
else [],
)
@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()
total_time_ms = (end - start) * 1000
self.logger.debug(
'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ DomService.get_serialized_dom_tree completed'
)
# Build hierarchical timing breakdown as single multi-line string
timing_lines = [f'⏱️ Total DOM tree time: {total_time_ms:.2f}ms', '📊 Timing breakdown:']
# get_all_trees breakdown
get_all_trees_ms = timing_info.get('get_all_trees_total_ms', 0)
if get_all_trees_ms > 0:
timing_lines.append(f' ├─ get_all_trees: {get_all_trees_ms:.2f}ms')
iframe_scroll_ms = timing_info.get('iframe_scroll_detection_ms', 0)
cdp_parallel_ms = timing_info.get('cdp_parallel_calls_ms', 0)
snapshot_proc_ms = timing_info.get('snapshot_processing_ms', 0)
if iframe_scroll_ms > 0.01:
timing_lines.append(f' │ ├─ iframe_scroll_detection: {iframe_scroll_ms:.2f}ms')
if cdp_parallel_ms > 0.01:
timing_lines.append(f' │ ├─ cdp_parallel_calls: {cdp_parallel_ms:.2f}ms')
if snapshot_proc_ms > 0.01:
timing_lines.append(f' │ └─ snapshot_processing: {snapshot_proc_ms:.2f}ms')
# build_ax_lookup
build_ax_ms = timing_info.get('build_ax_lookup_ms', 0)
if build_ax_ms > 0.01:
timing_lines.append(f' ├─ build_ax_lookup: {build_ax_ms:.2f}ms')
# build_snapshot_lookup
build_snapshot_ms = timing_info.get('build_snapshot_lookup_ms', 0)
if build_snapshot_ms > 0.01:
timing_lines.append(f' ├─ build_snapshot_lookup: {build_snapshot_ms:.2f}ms')
# construct_enhanced_tree
construct_tree_ms = timing_info.get('construct_enhanced_tree_ms', 0)
if construct_tree_ms > 0.01:
timing_lines.append(f' ├─ construct_enhanced_tree: {construct_tree_ms:.2f}ms')
# serialize_accessible_elements breakdown
serialize_total_ms = timing_info.get('serialize_accessible_elements_total_ms', 0)
if serialize_total_ms > 0.01:
timing_lines.append(f' ├─ serialize_accessible_elements: {serialize_total_ms:.2f}ms')
create_simp_ms = timing_info.get('create_simplified_tree_ms', 0)
paint_order_ms = timing_info.get('calculate_paint_order_ms', 0)
optimize_ms = timing_info.get('optimize_tree_ms', 0)
bbox_ms = timing_info.get('bbox_filtering_ms', 0)
assign_idx_ms = timing_info.get('assign_interactive_indices_ms', 0)
clickable_ms = timing_info.get('clickable_detection_time_ms', 0)
if create_simp_ms > 0.01:
timing_lines.append(f' │ ├─ create_simplified_tree: {create_simp_ms:.2f}ms')
if clickable_ms > 0.01:
timing_lines.append(f' │ │ └─ clickable_detection: {clickable_ms:.2f}ms')
if paint_order_ms > 0.01:
timing_lines.append(f' │ ├─ calculate_paint_order: {paint_order_ms:.2f}ms')
if optimize_ms > 0.01:
timing_lines.append(f' │ ├─ optimize_tree: {optimize_ms:.2f}ms')
if bbox_ms > 0.01:
timing_lines.append(f' │ ├─ bbox_filtering: {bbox_ms:.2f}ms')
if assign_idx_ms > 0.01:
timing_lines.append(f' │ └─ assign_interactive_indices: {assign_idx_ms:.2f}ms')
# Overheads
get_dom_overhead_ms = timing_info.get('get_dom_tree_overhead_ms', 0)
serialize_overhead_ms = timing_info.get('serialization_overhead_ms', 0)
get_serialized_overhead_ms = timing_info.get('get_serialized_dom_tree_overhead_ms', 0)
if get_dom_overhead_ms > 0.1:
timing_lines.append(f' ├─ get_dom_tree_overhead: {get_dom_overhead_ms:.2f}ms')
if serialize_overhead_ms > 0.1:
timing_lines.append(f' ├─ serialization_overhead: {serialize_overhead_ms:.2f}ms')
if get_serialized_overhead_ms > 0.1:
timing_lines.append(f' └─ get_serialized_dom_tree_overhead: {get_serialized_overhead_ms:.2f}ms')
# Calculate total tracked time for validation
main_operations_ms = (
get_all_trees_ms
+ build_ax_ms
+ build_snapshot_ms
+ construct_tree_ms
+ serialize_total_ms
+ get_dom_overhead_ms
+ serialize_overhead_ms
+ get_serialized_overhead_ms
)
untracked_time_ms = total_time_ms - main_operations_ms
if untracked_time_ms > 1.0: # Only log if significant
timing_lines.append(f' ⚠️ untracked_time: {untracked_time_ms:.2f}ms')
# Single log call with all timing info
self.logger.debug('\n'.join(timing_lines))
# 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...')
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
def _detect_pagination_buttons(self, selector_map: dict[int, EnhancedDOMTreeNode]) -> list['PaginationButton']:
"""Detect pagination buttons from the DOM selector map.
Args:
selector_map: Dictionary mapping element indices to DOM tree nodes
Returns:
List of PaginationButton instances found in the DOM
"""
from browser_use.browser.views import PaginationButton
pagination_buttons_data = []
try:
self.logger.debug('🔍 DOMWatchdog._detect_pagination_buttons: Detecting pagination buttons...')
pagination_buttons_raw = DomService.detect_pagination_buttons(selector_map)
# Convert to PaginationButton instances
pagination_buttons_data = [
PaginationButton(
button_type=btn['button_type'], # type: ignore
backend_node_id=btn['backend_node_id'], # type: ignore
text=btn['text'], # type: ignore
selector=btn['selector'], # type: ignore
is_disabled=btn['is_disabled'], # type: ignore
)
for btn in pagination_buttons_raw
]
if pagination_buttons_data:
self.logger.debug(
f'🔍 DOMWatchdog._detect_pagination_buttons: Found {len(pagination_buttons_data)} pagination buttons'
)
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog._detect_pagination_buttons: Pagination detection failed: {e}')
return pagination_buttons_data
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_or_create_cdp_session() handles focus validation automatically
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,779 @@
"""HAR Recording Watchdog for Browser-Use sessions.
Captures HTTPS network activity via CDP Network domain and writes a HAR 1.2
file on browser shutdown. Respects `record_har_content` (omit/embed/attach)
and `record_har_mode` (full/minimal).
"""
from __future__ import annotations
import base64
import hashlib
import json
from dataclasses import dataclass, field
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import ClassVar
from bubus import BaseEvent
from cdp_use.cdp.network.events import (
DataReceivedEvent,
LoadingFailedEvent,
LoadingFinishedEvent,
RequestWillBeSentEvent,
ResponseReceivedEvent,
)
from cdp_use.cdp.page.events import FrameNavigatedEvent, LifecycleEventEvent
from browser_use.browser.events import BrowserConnectedEvent, BrowserStopEvent
from browser_use.browser.watchdog_base import BaseWatchdog
@dataclass
class _HarContent:
mime_type: str | None = None
text_b64: str | None = None # for embed
file_rel: str | None = None # for attach
size: int | None = None
@dataclass
class _HarEntryBuilder:
request_id: str = ''
frame_id: str | None = None
document_url: str | None = None
url: str | None = None
method: str | None = None
request_headers: dict = field(default_factory=dict)
request_body: bytes | None = None
post_data: str | None = None # CDP postData field
status: int | None = None
status_text: str | None = None
response_headers: dict = field(default_factory=dict)
mime_type: str | None = None
encoded_data: bytearray = field(default_factory=bytearray)
failed: bool = False
# timing info (CDP timestamps are monotonic seconds); wallTime is epoch seconds
ts_request: float | None = None
wall_time_request: float | None = None
ts_response: float | None = None
ts_finished: float | None = None
encoded_data_length: int | None = None
response_body: bytes | None = None
content_length: int | None = None # From Content-Length header
protocol: str | None = None
server_ip_address: str | None = None
server_port: int | None = None
security_details: dict | None = None
transfer_size: int | None = None
def _is_https(url: str | None) -> bool:
return bool(url and url.lower().startswith('https://'))
def _origin(url: str) -> str:
# Very small origin extractor, assumes https URLs
# https://host[:port]/...
if not url:
return ''
try:
without_scheme = url.split('://', 1)[1]
host_port = without_scheme.split('/', 1)[0]
return f'https://{host_port}'
except Exception:
return ''
def _mime_to_extension(mime_type: str | None) -> str:
"""Map MIME type to file extension, matching Playwright's behavior."""
if not mime_type:
return 'bin'
mime_lower = mime_type.lower().split(';')[0].strip()
# Common MIME type to extension mapping
mime_map = {
'text/html': 'html',
'text/css': 'css',
'text/javascript': 'js',
'application/javascript': 'js',
'application/x-javascript': 'js',
'application/json': 'json',
'application/xml': 'xml',
'text/xml': 'xml',
'text/plain': 'txt',
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/x-icon': 'ico',
'font/woff': 'woff',
'font/woff2': 'woff2',
'application/font-woff': 'woff',
'application/font-woff2': 'woff2',
'application/x-font-woff': 'woff',
'application/x-font-woff2': 'woff2',
'font/ttf': 'ttf',
'application/x-font-ttf': 'ttf',
'font/otf': 'otf',
'application/x-font-opentype': 'otf',
'application/pdf': 'pdf',
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'video/mp4': 'mp4',
'video/webm': 'webm',
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/wav': 'wav',
'audio/ogg': 'ogg',
}
return mime_map.get(mime_lower, 'bin')
def _generate_har_filename(content: bytes, mime_type: str | None) -> str:
"""Generate a hash-based filename for HAR attach mode, matching Playwright's format."""
content_hash = hashlib.sha1(content).hexdigest()
extension = _mime_to_extension(mime_type)
return f'{content_hash}.{extension}'
class HarRecordingWatchdog(BaseWatchdog):
"""Collects HTTPS requests/responses and writes a HAR 1.2 file on stop."""
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent]
EMITS: ClassVar[list[type[BaseEvent]]] = []
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._enabled: bool = False
self._entries: dict[str, _HarEntryBuilder] = {}
self._top_level_pages: dict[
str, dict
] = {} # frameId -> {url, title, startedDateTime, monotonic_start, onContentLoad, onLoad}
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
profile = self.browser_session.browser_profile
if not profile.record_har_path:
return
# Normalize config
self._content_mode = (profile.record_har_content or 'embed').lower()
self._mode = (profile.record_har_mode or 'full').lower()
self._har_path = Path(str(profile.record_har_path)).expanduser().resolve()
self._har_dir = self._har_path.parent
self._har_dir.mkdir(parents=True, exist_ok=True)
try:
# Enable Network and Page domains for events
cdp_session = await self.browser_session.get_or_create_cdp_session()
await cdp_session.cdp_client.send.Network.enable(session_id=cdp_session.session_id)
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
# Query browser version for HAR log.browser
try:
version_info = await self.browser_session.cdp_client.send.Browser.getVersion()
self._browser_name = version_info.get('product') or 'Chromium'
self._browser_version = version_info.get('jsVersion') or ''
except Exception:
self._browser_name = 'Chromium'
self._browser_version = ''
cdp = self.browser_session.cdp_client.register
cdp.Network.requestWillBeSent(self._on_request_will_be_sent)
cdp.Network.responseReceived(self._on_response_received)
cdp.Network.dataReceived(self._on_data_received)
cdp.Network.loadingFinished(self._on_loading_finished)
cdp.Network.loadingFailed(self._on_loading_failed)
cdp.Page.lifecycleEvent(self._on_lifecycle_event)
cdp.Page.frameNavigated(self._on_frame_navigated)
self._enabled = True
self.logger.info(f'📊 Starting HAR recording to {self._har_path}')
except Exception as e:
self.logger.warning(f'Failed to enable HAR recording: {e}')
self._enabled = False
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
if not self._enabled:
return
try:
await self._write_har()
self.logger.info(f'📊 HAR file saved: {self._har_path}')
except Exception as e:
self.logger.warning(f'Failed to write HAR: {e}')
# =============== CDP Event Handlers (sync) ==================
def _on_request_will_be_sent(self, params: RequestWillBeSentEvent, session_id: str | None) -> None:
try:
req = params.get('request', {}) if hasattr(params, 'get') else getattr(params, 'request', {})
url = req.get('url') if isinstance(req, dict) else getattr(req, 'url', None)
if not _is_https(url):
return # HTTPS-only requirement (only HTTPS requests are recorded for now)
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
if not request_id:
return
entry = self._entries.setdefault(request_id, _HarEntryBuilder(request_id=request_id))
entry.url = url
entry.method = req.get('method') if isinstance(req, dict) else getattr(req, 'method', None)
entry.post_data = req.get('postData') if isinstance(req, dict) else getattr(req, 'postData', None)
# Convert headers to plain dict, handling various formats
headers_raw = req.get('headers') if isinstance(req, dict) else getattr(req, 'headers', None)
if headers_raw is None:
entry.request_headers = {}
elif isinstance(headers_raw, dict):
entry.request_headers = {k.lower(): str(v) for k, v in headers_raw.items()}
elif isinstance(headers_raw, list):
entry.request_headers = {
h.get('name', '').lower(): str(h.get('value') or '') for h in headers_raw if isinstance(h, dict)
}
else:
# Handle Headers type or other formats - convert to dict
try:
headers_dict = dict(headers_raw) if hasattr(headers_raw, '__iter__') else {}
entry.request_headers = {k.lower(): str(v) for k, v in headers_dict.items()}
except Exception:
entry.request_headers = {}
entry.frame_id = params.get('frameId') if hasattr(params, 'get') else getattr(params, 'frameId', None)
entry.document_url = (
params.get('documentURL')
if hasattr(params, 'get')
else getattr(params, 'documentURL', None) or entry.document_url
)
# Timing anchors
entry.ts_request = params.get('timestamp') if hasattr(params, 'get') else getattr(params, 'timestamp', None)
entry.wall_time_request = params.get('wallTime') if hasattr(params, 'get') else getattr(params, 'wallTime', None)
# Track top-level navigations for page context
req_type = params.get('type') if hasattr(params, 'get') else getattr(params, 'type', None)
is_same_doc = (
params.get('isSameDocument', False) if hasattr(params, 'get') else getattr(params, 'isSameDocument', False)
)
if req_type == 'Document' and not is_same_doc:
# best-effort: consider as navigation
if entry.frame_id and url:
if entry.frame_id not in self._top_level_pages:
self._top_level_pages[entry.frame_id] = {
'url': str(url),
'title': str(url), # Default to URL, will be updated from DOM
'startedDateTime': entry.wall_time_request,
'monotonic_start': entry.ts_request, # Track monotonic start time for timing calculations
'onContentLoad': -1,
'onLoad': -1,
}
else:
# Update startedDateTime and monotonic_start if this is earlier
page_info = self._top_level_pages[entry.frame_id]
if entry.wall_time_request and (
page_info['startedDateTime'] is None or entry.wall_time_request < page_info['startedDateTime']
):
page_info['startedDateTime'] = entry.wall_time_request
page_info['monotonic_start'] = entry.ts_request
except Exception as e:
self.logger.debug(f'requestWillBeSent handling error: {e}')
def _on_response_received(self, params: ResponseReceivedEvent, session_id: str | None) -> None:
try:
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
if not request_id or request_id not in self._entries:
return
response = params.get('response', {}) if hasattr(params, 'get') else getattr(params, 'response', {})
entry = self._entries[request_id]
entry.status = response.get('status') if isinstance(response, dict) else getattr(response, 'status', None)
entry.status_text = (
response.get('statusText') if isinstance(response, dict) else getattr(response, 'statusText', None)
)
# Extract Content-Length for compression calculation (before converting headers)
headers_raw = response.get('headers') if isinstance(response, dict) else getattr(response, 'headers', None)
if headers_raw:
if isinstance(headers_raw, dict):
cl_str = headers_raw.get('content-length') or headers_raw.get('Content-Length')
elif isinstance(headers_raw, list):
cl_header = next(
(h for h in headers_raw if isinstance(h, dict) and h.get('name', '').lower() == 'content-length'), None
)
cl_str = cl_header.get('value') if cl_header else None
else:
cl_str = None
if cl_str:
try:
entry.content_length = int(cl_str)
except Exception:
pass
# Convert headers to plain dict, handling various formats
if headers_raw is None:
entry.response_headers = {}
elif isinstance(headers_raw, dict):
entry.response_headers = {k.lower(): str(v) for k, v in headers_raw.items()}
elif isinstance(headers_raw, list):
entry.response_headers = {
h.get('name', '').lower(): str(h.get('value') or '') for h in headers_raw if isinstance(h, dict)
}
else:
# Handle Headers type or other formats - convert to dict
try:
headers_dict = dict(headers_raw) if hasattr(headers_raw, '__iter__') else {}
entry.response_headers = {k.lower(): str(v) for k, v in headers_dict.items()}
except Exception:
entry.response_headers = {}
entry.mime_type = response.get('mimeType') if isinstance(response, dict) else getattr(response, 'mimeType', None)
entry.ts_response = params.get('timestamp') if hasattr(params, 'get') else getattr(params, 'timestamp', None)
protocol_raw = response.get('protocol') if isinstance(response, dict) else getattr(response, 'protocol', None)
if protocol_raw:
protocol_lower = str(protocol_raw).lower()
if protocol_lower == 'h2' or protocol_lower.startswith('http/2'):
entry.protocol = 'HTTP/2.0'
elif protocol_lower.startswith('http/1.1'):
entry.protocol = 'HTTP/1.1'
elif protocol_lower.startswith('http/1.0'):
entry.protocol = 'HTTP/1.0'
else:
entry.protocol = str(protocol_raw).upper()
entry.server_ip_address = (
response.get('remoteIPAddress') if isinstance(response, dict) else getattr(response, 'remoteIPAddress', None)
)
server_port_raw = response.get('remotePort') if isinstance(response, dict) else getattr(response, 'remotePort', None)
if server_port_raw is not None:
try:
entry.server_port = int(server_port_raw)
except (ValueError, TypeError):
pass
# Extract security details (TLS info)
security_details_raw = (
response.get('securityDetails') if isinstance(response, dict) else getattr(response, 'securityDetails', None)
)
if security_details_raw:
try:
entry.security_details = dict(security_details_raw)
except Exception:
pass
except Exception as e:
self.logger.debug(f'responseReceived handling error: {e}')
def _on_data_received(self, params: DataReceivedEvent, session_id: str | None) -> None:
try:
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
if not request_id or request_id not in self._entries:
return
data = params.get('data') if hasattr(params, 'get') else getattr(params, 'data', None)
if isinstance(data, str):
try:
self._entries[request_id].encoded_data.extend(data.encode('latin1'))
except Exception:
pass
except Exception as e:
self.logger.debug(f'dataReceived handling error: {e}')
def _on_loading_finished(self, params: LoadingFinishedEvent, session_id: str | None) -> None:
try:
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
if not request_id or request_id not in self._entries:
return
entry = self._entries[request_id]
entry.ts_finished = params.get('timestamp')
# Fetch response body via CDP as dataReceived may be incomplete
import asyncio as _asyncio
async def _fetch_body(self_ref, req_id, sess_id):
try:
resp = await self_ref.browser_session.cdp_client.send.Network.getResponseBody(
params={'requestId': req_id}, session_id=sess_id
)
data = resp.get('body', b'')
if resp.get('base64Encoded'):
import base64 as _b64
data = _b64.b64decode(data)
else:
# Ensure data is bytes even if CDP returns a string
if isinstance(data, str):
data = data.encode('utf-8', errors='replace')
# Ensure we always have bytes
if not isinstance(data, bytes):
data = bytes(data) if data else b''
entry.response_body = data
except Exception:
pass
# Always schedule the response body fetch task
_asyncio.create_task(_fetch_body(self, request_id, session_id))
encoded_length = (
params.get('encodedDataLength') if hasattr(params, 'get') else getattr(params, 'encodedDataLength', None)
)
if encoded_length is not None:
try:
entry.encoded_data_length = int(encoded_length)
entry.transfer_size = entry.encoded_data_length
except Exception:
entry.encoded_data_length = None
except Exception as e:
self.logger.debug(f'loadingFinished handling error: {e}')
def _on_loading_failed(self, params: LoadingFailedEvent, session_id: str | None) -> None:
try:
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
if request_id and request_id in self._entries:
self._entries[request_id].failed = True
except Exception as e:
self.logger.debug(f'loadingFailed handling error: {e}')
# ===================== HAR Writing ==========================
def _on_lifecycle_event(self, params: LifecycleEventEvent, session_id: str | None) -> None:
"""Handle Page.lifecycleEvent for tracking page load timings."""
try:
frame_id = params.get('frameId') if hasattr(params, 'get') else getattr(params, 'frameId', None)
name = params.get('name') if hasattr(params, 'get') else getattr(params, 'name', None)
timestamp = params.get('timestamp') if hasattr(params, 'get') else getattr(params, 'timestamp', None)
if not frame_id or not name or frame_id not in self._top_level_pages:
return
page_info = self._top_level_pages[frame_id]
# Use monotonic_start instead of startedDateTime (wall-clock) for timing calculations
monotonic_start = page_info.get('monotonic_start')
if name == 'DOMContentLoaded' and monotonic_start is not None:
# Calculate milliseconds since page start using monotonic timestamps
try:
elapsed_ms = int(round((timestamp - monotonic_start) * 1000))
page_info['onContentLoad'] = max(0, elapsed_ms)
except Exception:
pass
elif name == 'load' and monotonic_start is not None:
try:
elapsed_ms = int(round((timestamp - monotonic_start) * 1000))
page_info['onLoad'] = max(0, elapsed_ms)
except Exception:
pass
except Exception as e:
self.logger.debug(f'lifecycleEvent handling error: {e}')
def _on_frame_navigated(self, params: FrameNavigatedEvent, session_id: str | None) -> None:
"""Handle Page.frameNavigated to update page title from DOM."""
try:
frame = params.get('frame') if hasattr(params, 'get') else getattr(params, 'frame', None)
if not frame:
return
frame_id = frame.get('id') if isinstance(frame, dict) else getattr(frame, 'id', None)
title = (
frame.get('name') or frame.get('url')
if isinstance(frame, dict)
else getattr(frame, 'name', None) or getattr(frame, 'url', None)
)
if frame_id and frame_id in self._top_level_pages:
# Try to get actual page title via Runtime.evaluate if possible
# For now, use frame name or URL as fallback
if title:
self._top_level_pages[frame_id]['title'] = str(title)
except Exception as e:
self.logger.debug(f'frameNavigated handling error: {e}')
# ===================== HAR Writing ==========================
async def _write_har(self) -> None:
# Filter by mode and HTTPS already respected at collection time
entries = [e for e in self._entries.values() if self._include_entry(e)]
har_entries = []
sidecar_dir: Path | None = None
if self._content_mode == 'attach':
sidecar_dir = self._har_dir / f'{self._har_path.stem}_har_parts'
sidecar_dir.mkdir(parents=True, exist_ok=True)
for e in entries:
content_obj: dict = {'mimeType': e.mime_type or ''}
# Get body data, preferring response_body over encoded_data
if e.response_body is not None:
body_data = e.response_body
else:
body_data = e.encoded_data
# Defensive conversion: ensure body_data is always bytes
if isinstance(body_data, str):
body_bytes = body_data.encode('utf-8', errors='replace')
elif isinstance(body_data, bytearray):
body_bytes = bytes(body_data)
elif isinstance(body_data, bytes):
body_bytes = body_data
else:
# Fallback: try to convert to bytes
try:
body_bytes = bytes(body_data) if body_data else b''
except (TypeError, ValueError):
body_bytes = b''
content_size = len(body_bytes)
# Calculate compression (bytes saved by compression)
compression = 0
if e.content_length is not None and e.encoded_data_length is not None:
compression = max(0, e.content_length - e.encoded_data_length)
if self._content_mode == 'embed' and content_size > 0:
# Prefer plain text; fallback to base64 only if decoding fails
try:
text_decoded = body_bytes.decode('utf-8')
content_obj['text'] = text_decoded
content_obj['size'] = content_size
content_obj['compression'] = compression
except UnicodeDecodeError:
content_obj['text'] = base64.b64encode(body_bytes).decode('ascii')
content_obj['encoding'] = 'base64'
content_obj['size'] = content_size
content_obj['compression'] = compression
elif self._content_mode == 'attach' and content_size > 0 and sidecar_dir is not None:
filename = _generate_har_filename(body_bytes, e.mime_type)
(sidecar_dir / filename).write_bytes(body_bytes)
content_obj['_file'] = filename
content_obj['size'] = content_size
content_obj['compression'] = compression
else:
# omit or empty
content_obj['size'] = content_size
if content_size > 0:
content_obj['compression'] = compression
started_date_time, total_time_ms, timings = self._compute_timings(e)
req_headers_list = [{'name': k, 'value': str(v)} for k, v in (e.request_headers or {}).items()]
resp_headers_list = [{'name': k, 'value': str(v)} for k, v in (e.response_headers or {}).items()]
request_headers_size = self._calc_headers_size(e.method or 'GET', e.url or '', req_headers_list)
response_headers_size = self._calc_headers_size(None, None, resp_headers_list)
request_body_size = self._calc_request_body_size(e)
request_post_data = None
if e.post_data and self._content_mode != 'omit':
if self._content_mode == 'embed':
request_post_data = {'mimeType': e.request_headers.get('content-type', ''), 'text': e.post_data}
elif self._content_mode == 'attach' and sidecar_dir is not None:
post_data_bytes = e.post_data.encode('utf-8')
req_mime_type = e.request_headers.get('content-type', 'text/plain')
req_filename = _generate_har_filename(post_data_bytes, req_mime_type)
(sidecar_dir / req_filename).write_bytes(post_data_bytes)
request_post_data = {
'mimeType': req_mime_type,
'_file': req_filename,
}
http_version = e.protocol if e.protocol else 'HTTP/1.1'
response_body_size = e.transfer_size
if response_body_size is None:
response_body_size = e.encoded_data_length
if response_body_size is None:
response_body_size = content_size if content_size > 0 else -1
entry_dict = {
'startedDateTime': started_date_time,
'time': total_time_ms,
'request': {
'method': e.method or 'GET',
'url': e.url or '',
'httpVersion': http_version,
'headers': req_headers_list,
'queryString': [],
'cookies': [],
'headersSize': request_headers_size,
'bodySize': request_body_size,
'postData': request_post_data,
},
'response': {
'status': e.status or 0,
'statusText': e.status_text or '',
'httpVersion': http_version,
'headers': resp_headers_list,
'cookies': [],
'content': content_obj,
'redirectURL': '',
'headersSize': response_headers_size,
'bodySize': response_body_size,
},
'cache': {},
'timings': timings,
'pageref': self._page_ref_for_entry(e),
}
# Add security/TLS details if available
if e.server_ip_address:
entry_dict['serverIPAddress'] = e.server_ip_address
if e.server_port is not None:
entry_dict['_serverPort'] = e.server_port
if e.security_details:
# Filter to match Playwright's minimal security details set
security_filtered = {}
if 'protocol' in e.security_details:
security_filtered['protocol'] = e.security_details['protocol']
if 'subjectName' in e.security_details:
security_filtered['subjectName'] = e.security_details['subjectName']
if 'issuer' in e.security_details:
security_filtered['issuer'] = e.security_details['issuer']
if 'validFrom' in e.security_details:
security_filtered['validFrom'] = e.security_details['validFrom']
if 'validTo' in e.security_details:
security_filtered['validTo'] = e.security_details['validTo']
if security_filtered:
entry_dict['_securityDetails'] = security_filtered
if e.transfer_size is not None:
entry_dict['response']['_transferSize'] = e.transfer_size
har_entries.append(entry_dict)
# Try to include our library version in creator
try:
bu_version = importlib_metadata.version('browser-use')
except Exception:
# Fallback when running from source without installed package metadata
bu_version = 'dev'
har_obj = {
'log': {
'version': '1.2',
'creator': {'name': 'browser-use', 'version': bu_version},
'browser': {'name': self._browser_name, 'version': self._browser_version},
'pages': [
{
'id': f'page@{pid}', # Use Playwright format: "page@{frame_id}"
'title': page_info.get('title', page_info.get('url', '')),
'startedDateTime': self._format_page_started_datetime(page_info.get('startedDateTime')),
'pageTimings': (
(lambda _ocl, _ol: ({k: v for k, v in (('onContentLoad', _ocl), ('onLoad', _ol)) if v is not None}))(
(page_info.get('onContentLoad') if page_info.get('onContentLoad', -1) >= 0 else None),
(page_info.get('onLoad') if page_info.get('onLoad', -1) >= 0 else None),
)
),
}
for pid, page_info in self._top_level_pages.items()
],
'entries': har_entries,
}
}
tmp_path = self._har_path.with_suffix(self._har_path.suffix + '.tmp')
# Write as bytes explicitly to avoid any text/binary mode confusion in different environments
tmp_path.write_bytes(json.dumps(har_obj, indent=2, ensure_ascii=False).encode('utf-8'))
tmp_path.replace(self._har_path)
def _format_page_started_datetime(self, timestamp: float | None) -> str:
"""Format page startedDateTime from timestamp."""
if timestamp is None:
return ''
try:
from datetime import datetime, timezone
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace('+00:00', 'Z')
except Exception:
return ''
def _page_ref_for_entry(self, e: _HarEntryBuilder) -> str | None:
# Use Playwright format: "page@{frame_id}" if frame_id is known
if e.frame_id and e.frame_id in self._top_level_pages:
return f'page@{e.frame_id}'
return None
def _include_entry(self, e: _HarEntryBuilder) -> bool:
if not _is_https(e.url):
return False
# Filter out favicon requests (matching Playwright behavior)
if e.url and '/favicon.ico' in e.url.lower():
return False
if getattr(self, '_mode', 'full') == 'full':
return True
# minimal: include main document and same-origin subresources
if e.frame_id and e.frame_id in self._top_level_pages:
page_info = self._top_level_pages[e.frame_id]
page_url = page_info.get('url') if isinstance(page_info, dict) else page_info
return _origin(e.url or '') == _origin(page_url or '')
return False
# ===================== Helpers ==============================
def _compute_timings(self, e: _HarEntryBuilder) -> tuple[str, int, dict]:
# startedDateTime from wall_time_request in ISO8601 Z
started = ''
try:
if e.wall_time_request is not None:
from datetime import datetime, timezone
started = datetime.fromtimestamp(e.wall_time_request, tz=timezone.utc).isoformat().replace('+00:00', 'Z')
except Exception:
started = ''
# Calculate timings - CDP doesn't always provide DNS/connect/SSL breakdown
# Default to 0 for unavailable timings, calculate what we can from timestamps
dns_ms = 0
connect_ms = 0
ssl_ms = 0
send_ms = 0
wait_ms = 0
receive_ms = 0
if e.ts_request is not None and e.ts_response is not None:
wait_ms = max(0, int(round((e.ts_response - e.ts_request) * 1000)))
if e.ts_response is not None and e.ts_finished is not None:
receive_ms = max(0, int(round((e.ts_finished - e.ts_response) * 1000)))
# Note: DNS, connect, and SSL timings would require additional CDP events or ResourceTiming API
# For now, we structure the timings dict to match Playwright format
# but leave DNS/connect/SSL as 0 since CDP doesn't provide this breakdown directly
total = dns_ms + connect_ms + ssl_ms + send_ms + wait_ms + receive_ms
return (
started,
total,
{
'dns': dns_ms,
'connect': connect_ms,
'ssl': ssl_ms,
'send': send_ms,
'wait': wait_ms,
'receive': receive_ms,
},
)
def _calc_headers_size(self, method: str | None, url: str | None, headers_list: list[dict]) -> int:
try:
# Approximate per RFC: sum of header lines + CRLF; include request/status line only for request
size = 0
if method and url:
# Use HTTP/1.1 request line approximation
size += len(f'{method} {url} HTTP/1.1\r\n'.encode('latin1'))
for h in headers_list:
size += len(f'{h.get("name", "")}: {h.get("value", "")}\r\n'.encode('latin1'))
size += len(b'\r\n')
return size
except Exception:
return -1
def _calc_request_body_size(self, e: _HarEntryBuilder) -> int:
# Try Content-Length header first; else post_data; else request_body; else 0 for GET/HEAD, -1 if unknown
try:
cl = None
if e.request_headers:
cl = e.request_headers.get('content-length') or e.request_headers.get('Content-Length')
if cl is not None:
return int(cl)
if e.post_data:
return len(e.post_data.encode('utf-8'))
if e.request_body is not None:
return len(e.request_body)
# GET/HEAD requests typically have no body
if e.method and e.method.upper() in ('GET', 'HEAD'):
return 0
except Exception:
pass
return -1
@@ -0,0 +1,506 @@
"""Local browser watchdog for managing browser subprocess lifecycle."""
from __future__ import annotations
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:
from browser_use.browser.profile import BrowserChannel
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 (Playwright's Chromium preferred by default)
browser_path = self._find_installed_browser_path(channel=profile.channel)
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...')
self.logger.debug(
f'[LocalBrowserWatchdog] 📂 user_data_dir={profile.user_data_dir}, profile_directory={profile.profile_directory}'
)
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 only the temp dirs we created but didn't use
currently_used_dir = str(profile.user_data_dir)
unused_temp_dirs = [tmp_dir for tmp_dir in self._temp_dirs_to_cleanup if str(tmp_dir) != currently_used_dir]
for tmp_dir in unused_temp_dirs:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
# Keep only the in-use directory for cleanup during browser kill
if currently_used_dir and 'browseruse-tmp-' in currently_used_dir:
self._temp_dirs_to_cleanup = [Path(currently_used_dir)]
else:
self._temp_dirs_to_cleanup = []
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(channel: BrowserChannel | None = None) -> str | None:
"""Try to find browser executable from common fallback locations.
If a channel is specified, paths for that browser are searched first.
Falls back to all known browser paths if the channel-specific search fails.
Prioritizes:
1. Channel-specific paths (if channel is set to a non-default value)
2. Playwright bundled Chromium (when no channel or default channel specified)
3. System Chrome stable
4. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave -> Edge)
5. Playwright headless-shell fallback
Returns:
Path to browser executable or None if not found
"""
import glob
import platform
from pathlib import Path
from browser_use.browser.profile import BROWSERUSE_DEFAULT_CHANNEL, BrowserChannel
system = platform.system()
# Get playwright browsers path from environment variable if set
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
# Build tagged pattern lists per OS: (browser_group, path)
# browser_group is used to match against the requested channel
if system == 'Darwin': # macOS
if not playwright_path:
playwright_path = '~/Library/Caches/ms-playwright'
all_patterns = [
('chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
('chromium', f'{playwright_path}/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium'),
('chromium', '/Applications/Chromium.app/Contents/MacOS/Chromium'),
('chrome-canary', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'),
('brave', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'),
('msedge', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'),
('chromium', 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'
all_patterns = [
('chrome', '/usr/bin/google-chrome-stable'),
('chrome', '/usr/bin/google-chrome'),
('chrome', '/usr/local/bin/google-chrome'),
('chromium', f'{playwright_path}/chromium-*/chrome-linux*/chrome'),
('chromium', '/usr/bin/chromium'),
('chromium', '/usr/bin/chromium-browser'),
('chromium', '/usr/local/bin/chromium'),
('chromium', '/snap/bin/chromium'),
('chrome-beta', '/usr/bin/google-chrome-beta'),
('chrome-dev', '/usr/bin/google-chrome-dev'),
('brave', '/usr/bin/brave-browser'),
('msedge', '/usr/bin/microsoft-edge-stable'),
('msedge', '/usr/bin/microsoft-edge'),
('chromium', f'{playwright_path}/chromium_headless_shell-*/chrome-linux*/chrome'),
]
elif system == 'Windows':
if not playwright_path:
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
all_patterns = [
('chrome', r'C:\Program Files\Google\Chrome\Application\chrome.exe'),
('chrome', r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'),
('chrome', r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
('chrome', r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe'),
('chrome', r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe'),
('chromium', f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe'),
('chromium', r'C:\Program Files\Chromium\Application\chrome.exe'),
('chromium', r'C:\Program Files (x86)\Chromium\Application\chrome.exe'),
('chromium', r'%LOCALAPPDATA%\Chromium\Application\chrome.exe'),
('brave', r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe'),
('brave', r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe'),
('msedge', r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'),
('msedge', r'C:\Program Files\Microsoft\Edge\Application\msedge.exe'),
('msedge', r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe'),
('chromium', f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe'),
]
else:
all_patterns = []
# Map channel enum values to browser group tags
_channel_to_group: dict[BrowserChannel, str] = {
BrowserChannel.CHROME: 'chrome',
BrowserChannel.CHROME_BETA: 'chrome-beta',
BrowserChannel.CHROME_DEV: 'chrome-dev',
BrowserChannel.CHROME_CANARY: 'chrome-canary',
BrowserChannel.CHROMIUM: 'chromium',
BrowserChannel.MSEDGE: 'msedge',
BrowserChannel.MSEDGE_BETA: 'msedge',
BrowserChannel.MSEDGE_DEV: 'msedge',
BrowserChannel.MSEDGE_CANARY: 'msedge',
}
# Prioritize the target browser group, then fall back to the rest.
if channel and channel != BROWSERUSE_DEFAULT_CHANNEL and channel in _channel_to_group:
target_group = _channel_to_group[channel]
else:
target_group = _channel_to_group[BROWSERUSE_DEFAULT_CHANNEL]
prioritized = [p for g, p in all_patterns if g == target_group]
rest = [p for g, p in all_patterns if g != target_group]
patterns = prioritized + rest
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."""
import platform
# Build command - only use --with-deps on Linux (it fails on Windows/macOS)
cmd = ['uvx', 'playwright', 'install', 'chromium']
if platform.system() == 'Linux':
cmd.append('--with-deps')
# Run in subprocess with timeout
process = await asyncio.create_subprocess_exec(
*cmd,
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 chromium')
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://127.0.0.1:{port}/json/version') as resp:
if resp.status == 200:
# Chrome is ready
return f'http://127.0.0.1:{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,145 @@
"""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
# CRITICAL: Enable Page domain to receive dialog events
try:
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
self.logger.debug(f'✅ Enabled Page domain for session {cdp_session.session_id[-8:]}')
except Exception as e:
self.logger.debug(f'Failed to enable Page domain: {e}')
# 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')
try:
# Enable Page domain on root client too
await self.browser_session._cdp_client_root.send.Page.enable()
self.logger.debug('✅ Enabled Page domain on root CDP client')
except Exception as e:
self.logger.debug(f'Failed to enable Page domain on root: {e}')
# 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', '')
# Store the popup message in browser session for inclusion in browser state
if message:
formatted_message = f'[{dialog_type}] {message}'
self.browser_session._closed_popup_messages.append(formatted_message)
self.logger.debug(f'📝 Stored popup message: {formatted_message[:100]}')
# Choose action based on dialog type:
# - alert: accept=true (click OK to dismiss)
# - confirm: accept=true (click OK to proceed - safer for automation)
# - prompt: accept=false (click Cancel since we can't provide input)
# - beforeunload: accept=true (allow navigation)
should_accept = dialog_type in ('alert', 'confirm', 'beforeunload')
action_str = 'accepting (OK)' if should_accept else 'dismissing (Cancel)'
self.logger.info(f"🔔 JavaScript {dialog_type} dialog: '{message[:100]}' - {action_str}...")
dismissed = False
# Approach 1: Use the session that detected the dialog (most reliable)
if self.browser_session._cdp_client_root and session_id:
try:
self.logger.debug(f'🔄 Approach 1: Using detecting session {session_id[-8:]}')
await asyncio.wait_for(
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': should_accept},
session_id=session_id,
),
timeout=0.5,
)
dismissed = True
self.logger.info('✅ Dialog handled successfully via detecting session')
except (TimeoutError, Exception) as e:
self.logger.debug(f'Approach 1 failed: {type(e).__name__}')
# Approach 2: Try with current agent focus session
if not dismissed and self.browser_session._cdp_client_root and self.browser_session.agent_focus_target_id:
try:
# Use public API with focus=False to avoid changing focus during popup dismissal
cdp_session = await self.browser_session.get_or_create_cdp_session(
self.browser_session.agent_focus_target_id, focus=False
)
self.logger.debug(f'🔄 Approach 2: Using agent focus session {cdp_session.session_id[-8:]}')
await asyncio.wait_for(
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': should_accept},
session_id=cdp_session.session_id,
),
timeout=0.5,
)
dismissed = True
self.logger.info('✅ Dialog handled successfully via agent focus session')
except (TimeoutError, Exception) as e:
self.logger.debug(f'Approach 2 failed: {type(e).__name__}')
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,223 @@
"""Recording Watchdog for Browser Use Sessions."""
import asyncio
from pathlib import Path
from typing import Any, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.page.events import ScreencastFrameEvent
from pydantic import PrivateAttr
from uuid_extensions import uuid7str
from browser_use.browser.events import AgentFocusChangedEvent, 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
from browser_use.utils import create_task_with_error_handling
class RecordingWatchdog(BaseWatchdog):
"""
Manages video recording of a browser session using CDP screencasting.
"""
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent, AgentFocusChangedEvent]
EMITS: ClassVar[list[type[BaseEvent]]] = []
_recorder: VideoRecorderService | None = PrivateAttr(default=None)
_current_session_id: str | None = PrivateAttr(default=None)
_screencast_params: dict[str, Any] | None = PrivateAttr(default=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
video_format = getattr(profile, 'record_video_format', 'mp4').strip('.')
output_path = Path(profile.record_video_dir) / f'{uuid7str()}.{video_format}'
try:
await self.start_recording(output_path, size=profile.record_video_size, framerate=profile.record_video_framerate)
except RuntimeError as e:
# Preserve prior graceful degradation: a session configured with record_video_dir
# should not fail startup when video deps are missing or viewport detection fails.
self.logger.warning(f'Skipping video recording: {e}')
async def start_recording(
self,
output_path: Path,
size: ViewportSize | None = None,
framerate: int | None = None,
) -> Path:
"""
Begin recording the current session to `output_path`. Safe to call at any time
after the browser has connected.
Returns the resolved output path. Raises RuntimeError if recording is already active
or if the viewport size could not be determined.
"""
if self._recorder is not None:
raise RuntimeError(f'Recording already in progress (output: {self._recorder.output_path})')
if size is None:
self.logger.debug('record size not specified, detecting viewport size...')
size = await self._get_current_viewport_size()
if not size:
raise RuntimeError('Cannot start video recording: viewport size could not be determined.')
if framerate is None:
framerate = self.browser_session.browser_profile.record_video_framerate
output_path = Path(output_path)
self.logger.debug(f'Initializing video recorder → {output_path}')
recorder = VideoRecorderService(output_path=output_path, size=size, framerate=framerate)
recorder.start()
if not recorder._is_active:
raise RuntimeError(
'Failed to initialize video recorder — ensure optional deps are installed (`pip install "browser-use[video]"`).'
)
self._recorder = recorder
self.browser_session.cdp_client.register.Page.screencastFrame(self.on_screencastFrame)
self._screencast_params = {
'format': 'png',
'quality': 90,
'maxWidth': size['width'],
'maxHeight': size['height'],
'everyNthFrame': 1,
}
await self._start_screencast()
return output_path
async def stop_recording(self) -> Path | None:
"""
Stop any in-progress recording and finalize the output file.
Returns the path of the saved video, or None if no recording was active.
"""
if not self._recorder:
return None
recorder = self._recorder
session_id = self._current_session_id
self._recorder = None
self._current_session_id = None
self._screencast_params = None
if session_id:
try:
await self.browser_session.cdp_client.send.Page.stopScreencast(session_id=session_id)
except Exception as e:
self.logger.debug(f'Failed to stop CDP screencast on {session_id}: {e}')
output_path = recorder.output_path
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, recorder.stop_and_save)
return output_path
@property
def is_recording(self) -> bool:
"""Whether a recording is currently in progress."""
return self._recorder is not None
async def on_AgentFocusChangedEvent(self, event: AgentFocusChangedEvent) -> None:
"""
Switches video recording to the new tab.
"""
if self._recorder:
self.logger.debug(f'Agent focus changed to {event.target_id}, switching screencast...')
await self._start_screencast()
async def _start_screencast(self) -> None:
"""Starts screencast on the currently focused tab."""
if not self._recorder or not self._screencast_params:
return
try:
# Get the current session (for the focused target)
cdp_session = await self.browser_session.get_or_create_cdp_session()
# If we are already recording this session, do nothing
if self._current_session_id == cdp_session.session_id:
return
# Stop recording on the previous session
if self._current_session_id:
try:
# Use the root client to stop screencast on the specific session
await self.browser_session.cdp_client.send.Page.stopScreencast(session_id=self._current_session_id)
except Exception as e:
# It's possible the session is already closed
self.logger.debug(f'Failed to stop screencast on old session {self._current_session_id}: {e}')
self._current_session_id = cdp_session.session_id
# Start recording on the new session
await cdp_session.cdp_client.send.Page.startScreencast(
params=self._screencast_params, # type: ignore
session_id=cdp_session.session_id,
)
self.logger.info(f'📹 Started/Switched video recording to target {cdp_session.target_id}')
except Exception as e:
self.logger.error(f'Failed to switch screencast via CDP: {e}')
# If we fail to start on the new tab, we reset current session id
self._current_session_id = 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.
"""
# Only process frames from the current session we intend to record
# This handles race conditions where old session might still send frames before stop completes
if self._current_session_id and session_id != self._current_session_id:
return
if not self._recorder:
return
self._recorder.add_frame(event['data'])
create_task_with_error_handling(
self._ack_screencast_frame(event, session_id),
name='ack_screencast_frame',
logger_instance=self.logger,
suppress_exceptions=True,
)
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:
self.logger.debug('Stopping video recording and saving file...')
await self.stop_recording()
@@ -0,0 +1,88 @@
"""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:
# Validate focused target is a top-level page (not iframe/worker)
# CDP Page.captureScreenshot only works on page/tab targets
focused_target = self.browser_session.get_focused_target()
if focused_target and focused_target.target_type in ('page', 'tab'):
target_id = focused_target.target_id
else:
# Focused target is iframe/worker/missing - fall back to any page target
target_type_str = focused_target.target_type if focused_target else 'None'
self.logger.warning(f'[ScreenshotWatchdog] Focused target is {target_type_str}, falling back to page target')
page_targets = self.browser_session.get_page_targets()
if not page_targets:
raise BrowserError('[ScreenshotWatchdog] No page targets available for screenshot')
target_id = page_targets[-1].target_id
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=True)
# Remove highlights BEFORE taking the screenshot so they don't appear in the image.
# Done here (not in finally) so CancelledError is never swallowed — any await in a
# finally block can suppress external task cancellation.
# remove_highlights() has its own asyncio.timeout(3.0) internally so it won't block.
try:
await self.browser_session.remove_highlights()
except Exception:
pass
# Prepare screenshot parameters
params_dict: dict[str, Any] = {'format': 'png', 'captureBeyondViewport': event.full_page}
if event.clip:
params_dict['clip'] = {
'x': event.clip['x'],
'y': event.clip['y'],
'width': event.clip['width'],
'height': event.clip['height'],
'scale': 1,
}
params = CaptureScreenshotParameters(**params_dict)
# 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
@@ -0,0 +1,296 @@
"""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 (catches redirects to blocked domains)."""
# 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 blocked to non-allowed URL: {event.url} - redirecting to about:blank',
details={'url': event.url, 'target_id': event.target_id},
)
)
# Navigate to about:blank to keep session alive
# Agent will see the error and can continue with other tasks
try:
session = await self.browser_session.get_or_create_cdp_session(target_id=event.target_id)
await session.cdp_client.send.Page.navigate(params={'url': 'about:blank'}, session_id=session.session_id)
self.logger.info(f'⛔️ Navigated to about:blank after blocked URL: {event.url}')
except Exception as e:
self.logger.error(f'⛔️ Failed to navigate to about:blank: {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 _get_domain_variants(self, host: str) -> tuple[str, str]:
"""Get both variants of a domain (with and without www prefix).
Args:
host: The hostname to process
Returns:
Tuple of (original_host, variant_host)
- If host starts with www., variant is without www.
- Otherwise, variant is with www. prefix
"""
if host.startswith('www.'):
return (host, host[4:]) # ('www.example.com', 'example.com')
else:
return (host, f'www.{host}') # ('example.com', 'www.example.com')
def _is_ip_address(self, host: str) -> bool:
"""True iff `host` matches an IPv4 or IPv6 the browser would resolve.
Mirrors WHATWG host canonicalization so non-standard IPv4 encodings
(decimal, hex, octal, short-form, percent-encoded, Unicode digits)
can't bypass `block_ip_addresses`. Never raises — unrecognizable
hosts return False and fall through to domain-allowlist handling.
"""
import ipaddress
import socket
import unicodedata
from urllib.parse import unquote
bare = host.strip('[]')
try:
bare = unquote(bare)
except Exception:
pass
try:
bare = unicodedata.normalize('NFKC', bare)
except Exception:
pass
# IDNA label separators NFKC misses (U+3002, U+FF61 → U+3002).
bare = bare.replace('', '.').replace('', '.')
try:
ipaddress.ip_address(bare)
return True
except Exception:
pass
# Non-standard IPv4 (decimal, hex, octal, short-form) — `inet_aton`
# accepts the same liberal forms the kernel resolver does.
try:
socket.inet_aton(bare)
return True
except Exception:
return False
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
"""
# Always allow internal browser targets (before any other checks)
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
# Allow data: and blob: URLs (they don't have hostnames)
if parsed.scheme in ['data', 'blob']:
return True
# Get the actual host (domain)
host = parsed.hostname
if not host:
return False
# Check if IP addresses should be blocked (before domain checks)
if self.browser_session.browser_profile.block_ip_addresses:
if self._is_ip_address(host):
return False
# 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
# Check allowed domains (fast path for sets, slow path for lists with patterns)
if self.browser_session.browser_profile.allowed_domains:
allowed_domains = self.browser_session.browser_profile.allowed_domains
if isinstance(allowed_domains, set):
# Fast path: O(1) exact hostname match - check both www and non-www variants
host_variant, host_alt = self._get_domain_variants(host)
return host_variant in allowed_domains or host_alt in allowed_domains
else:
# Slow path: O(n) pattern matching for lists
for pattern in allowed_domains:
if self._is_url_match(url, host, parsed.scheme, pattern):
return True
return False
# Check prohibited domains (fast path for sets, slow path for lists with patterns)
if self.browser_session.browser_profile.prohibited_domains:
prohibited_domains = self.browser_session.browser_profile.prohibited_domains
if isinstance(prohibited_domains, set):
# Fast path: O(1) exact hostname match - check both www and non-www variants
host_variant, host_alt = self._get_domain_variants(host)
return host_variant not in prohibited_domains and host_alt not in prohibited_domains
else:
# Slow path: O(n) pattern matching for lists
for pattern in 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://* or http*://example.com/*
if fnmatch.fnmatch(url, pattern):
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,373 @@
"""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
from browser_use.utils import create_task_with_error_handling
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 = create_task_with_error_handling(
self._monitor_storage_changes(), name='monitor_storage_changes', logger_instance=self.logger, suppress_exceptions=True
)
# 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)
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, ensure_ascii=False), encoding='utf-8')
# 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']:
# Playwright exports session cookies with expires=0/-1. CDP treats expires=0 as expired.
# Normalize session cookies by omitting expires
normalized_cookies: list[Cookie] = []
for cookie in storage['cookies']:
if not isinstance(cookie, dict):
normalized_cookies.append(cookie) # type: ignore[arg-type]
continue
c = dict(cookie)
expires = c.get('expires')
if expires in (0, 0.0, -1, -1.0):
c.pop('expires', None)
normalized_cookies.append(Cookie(**c))
await self.browser_session._cdp_set_cookies(normalized_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']:
origin_value = origin.get('origin')
if not origin_value:
continue
# Scope storage restoration to its origin to avoid cross-site pollution.
if origin.get('localStorage'):
lines = []
for item in origin['localStorage']:
lines.append(f'window.localStorage.setItem({json.dumps(item["name"])}, {json.dumps(item["value"])});')
script = (
'(function(){\n'
f' if (window.location && window.location.origin !== {json.dumps(origin_value)}) return;\n'
' try {\n'
f' {" ".join(lines)}\n'
' } catch (e) {}\n'
'})();'
)
await self.browser_session._cdp_add_init_script(script)
if origin.get('sessionStorage'):
lines = []
for item in origin['sessionStorage']:
lines.append(
f'window.sessionStorage.setItem({json.dumps(item["name"])}, {json.dumps(item["value"])});'
)
script = (
'(function(){\n'
f' if (window.location && window.location.origin !== {json.dumps(origin_value)}) return;\n'
' try {\n'
f' {" ".join(lines)}\n'
' } catch (e) {}\n'
'})();'
)
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}')