diff --git a/.github/ISSUE_TEMPLATE/2_bug_report.yml b/.github/ISSUE_TEMPLATE/2_bug_report.yml
index d67aeb9fc..7a4e1ff98 100644
--- a/.github/ISSUE_TEMPLATE/2_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/2_bug_report.yml
@@ -14,7 +14,7 @@ body:
---
> [!IMPORTANT]
> 🙏 Please **go check *right now before filling this out* that you are *actually* on the [⬆️ LATEST VERSION](https://github.com/browser-use/browser-use/releases)**.
- > 🚀 We ship changes every hour and we might've already fixed your issue today!
+ > 🚀 We ship changes every hour and we might've already fixed your issue today!
>
> If you are running an old version, the **first thing we will ask you to do is *upgrade to the latest version* and try again**:
> - 🆕 [`beta`](https://docs.browser-use.com/development/local-setup): `uv pip install --upgrade git+https://github.com/browser-use/browser-use.git@main`
@@ -25,7 +25,7 @@ body:
attributes:
label: Browser Use Version
description: |
- What exact version of `browser-use` are you using? (Run `uv pip show browser-use` or `git log -n 1`)
+ What exact version of `browser-use` are you using? (Run `uv pip show browser-use` or `git log -n 1`)
**DO NOT WRITE `latest release` or `main` or a very old version or we will close your issue!**
placeholder: "e.g. 0.4.45 or 62760baaefd"
validations:
@@ -58,7 +58,7 @@ body:
agent = Agent(
task='...',
- llm=ChatOpenAI(model="gpt-4o"),
+ llm=ChatOpenAI(model="gpt-4.1-mini"),
browser_session=BrowserSession(headless=False),
)
...
@@ -70,8 +70,6 @@ body:
description: Which LLM model(s) are you using?
multiple: true
options:
- - gpt-4o
- - gpt-4o-mini
- gpt-4
- gpt-4.1
- gpt-4.1-mini
diff --git a/README.md b/README.md
index e9169ba69..304b0d51b 100644
--- a/README.md
+++ b/README.md
@@ -15,20 +15,20 @@
[](https://app.workweave.ai/reports/repository/org_T5Pvn3UBswTHIsN1dWS3voPg/881458615)
-[Deutsch](https://www.readme-i18n.com/browser-use/browser-use?lang=de) |
-[Español](https://www.readme-i18n.com/browser-use/browser-use?lang=es) |
-[français](https://www.readme-i18n.com/browser-use/browser-use?lang=fr) |
-[日本語](https://www.readme-i18n.com/browser-use/browser-use?lang=ja) |
-[한국어](https://www.readme-i18n.com/browser-use/browser-use?lang=ko) |
-[Português](https://www.readme-i18n.com/browser-use/browser-use?lang=pt) |
-[Русский](https://www.readme-i18n.com/browser-use/browser-use?lang=ru) |
+[Deutsch](https://www.readme-i18n.com/browser-use/browser-use?lang=de) |
+[Español](https://www.readme-i18n.com/browser-use/browser-use?lang=es) |
+[français](https://www.readme-i18n.com/browser-use/browser-use?lang=fr) |
+[日本語](https://www.readme-i18n.com/browser-use/browser-use?lang=ja) |
+[한국어](https://www.readme-i18n.com/browser-use/browser-use?lang=ko) |
+[Português](https://www.readme-i18n.com/browser-use/browser-use?lang=pt) |
+[Русский](https://www.readme-i18n.com/browser-use/browser-use?lang=ru) |
[中文](https://www.readme-i18n.com/browser-use/browser-use?lang=zh)
🌤️ Want to skip the setup? Use our [cloud](https://cloud.browser-use.com) for faster, scalable, stealth-enabled browser automation!
-**🚀 Use the latest version!**
+**🚀 Use the latest version!**
-> We ship every day improvements for **speed**, **accuracy**, and **UX**.
+> We ship every day improvements for **speed**, **accuracy**, and **UX**.
> ```bash
> uv pip install --upgrade browser-use
> ```
@@ -74,6 +74,22 @@ OPENAI_API_KEY=
For other settings, models, and more, check out the [documentation 📕](https://docs.browser-use.com).
+**🌤️ Want to use cloud browsers?** Simply add `cloud_browser=True` to your Browser config:
+
+```python
+from browser_use import Agent, Browser, ChatOpenAI
+
+agent = Agent(
+ task="Find the number of stars of the browser-use repo",
+ llm=ChatOpenAI(model="gpt-4.1-mini"),
+ browser=Browser(cloud_browser=True), # Uses Browser-Use cloud service
+)
+```
+
+First Set BROWSER_USE_API_KEY environment variable. You can get your API key from [here](https://cloud.browser-use.com).
+
+For other settings, models, and more, check out the [Cloud documentation 📕](https://docs.cloud.browser-use.com).
+
# Demos
@@ -254,11 +270,11 @@ If you use Browser Use in your research or project, please cite:
}
```
-

-
+

+
[](https://x.com/intent/user?screen_name=gregpr07)
[](https://x.com/intent/user?screen_name=mamagnus00)
-
+
diff --git a/browser_use/browser/cloud.py b/browser_use/browser/cloud.py
new file mode 100644
index 000000000..f15bb910e
--- /dev/null
+++ b/browser_use/browser/cloud.py
@@ -0,0 +1,286 @@
+"""Cloud browser service integration for browser-use.
+
+This module provides integration with the browser-use cloud browser service.
+When cloud_browser=True, it automatically creates a cloud browser instance
+and returns the CDP URL for connection.
+"""
+
+import logging
+import os
+
+import httpx
+from pydantic import BaseModel, Field
+
+from browser_use.sync.auth import CloudAuthConfig
+
+logger = logging.getLogger(__name__)
+
+
+class CloudBrowserResponse(BaseModel):
+ """Response from cloud browser API."""
+
+ id: str
+ status: str
+ liveUrl: str = Field(alias='liveUrl')
+ cdpUrl: str = Field(alias='cdpUrl')
+ timeoutAt: str = Field(alias='timeoutAt')
+ startedAt: str = Field(alias='startedAt')
+ finishedAt: str | None = Field(alias='finishedAt', default=None)
+
+
+class CloudBrowserError(Exception):
+ """Exception raised when cloud browser operations fail."""
+
+ pass
+
+
+class CloudBrowserAuthError(CloudBrowserError):
+ """Exception raised when cloud browser authentication fails."""
+
+ pass
+
+
+class CloudBrowserClient:
+ """Client for browser-use cloud browser service."""
+
+ def __init__(self, api_base_url: str = 'https://api.browser-use.com'):
+ self.api_base_url = api_base_url
+ self.client = httpx.AsyncClient(timeout=30.0)
+ self.current_session_id: str | None = None
+
+ async def create_browser(self) -> CloudBrowserResponse:
+ """Create a new cloud browser instance.
+
+ Returns:
+ CloudBrowserResponse: Contains CDP URL and other browser info
+
+ Raises:
+ CloudBrowserAuthError: If authentication fails
+ CloudBrowserError: If browser creation fails
+ """
+ url = f'{self.api_base_url}/api/v2/browsers'
+
+ # Try to get API key from environment variable first, then auth config
+ api_token = os.getenv('BROWSER_USE_API_KEY')
+
+ if not api_token:
+ # Fallback to auth config file
+ try:
+ auth_config = CloudAuthConfig.load_from_file()
+ api_token = auth_config.api_token
+ except Exception:
+ pass
+
+ if not api_token:
+ raise CloudBrowserAuthError(
+ 'No authentication token found. Please set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
+ )
+
+ headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json'}
+
+ # Empty request body as per API specification
+ request_body = {}
+
+ try:
+ logger.info('🌤️ Creating cloud browser instance...')
+
+ response = await self.client.post(url, headers=headers, json=request_body)
+
+ if response.status_code == 401:
+ raise CloudBrowserAuthError(
+ 'Authentication failed. Please make sure you have set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
+ )
+ 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}')
+ logger.debug(f'🌤️ Live URL: {browser_response.liveUrl}')
+
+ return browser_response
+
+ except httpx.TimeoutException:
+ raise CloudBrowserError('Timeout while creating cloud browser. Please try again.')
+ except httpx.ConnectError:
+ raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
+ except Exception as e:
+ if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
+ raise
+ raise CloudBrowserError(f'Unexpected error creating cloud browser: {e}')
+
+ async def stop_browser(self, session_id: str | None = None) -> CloudBrowserResponse:
+ """Stop a cloud browser session.
+
+ Args:
+ session_id: Session ID to stop. If None, uses current session.
+
+ Returns:
+ CloudBrowserResponse: Updated browser info with stopped status
+
+ Raises:
+ CloudBrowserAuthError: If authentication fails
+ CloudBrowserError: If stopping fails
+ """
+ if session_id is None:
+ session_id = self.current_session_id
+
+ if not session_id:
+ raise CloudBrowserError('No session ID provided and no current session available')
+
+ url = f'{self.api_base_url}/api/v2/browsers/{session_id}'
+
+ # Try to get API key from environment variable first, then auth config
+ api_token = os.getenv('BROWSER_USE_API_KEY')
+
+ if not api_token:
+ # Fallback to auth config file
+ try:
+ auth_config = CloudAuthConfig.load_from_file()
+ api_token = auth_config.api_token
+ except Exception:
+ pass
+
+ if not api_token:
+ raise CloudBrowserAuthError(
+ 'No authentication token found. Please set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
+ )
+
+ headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json'}
+
+ request_body = {'action': 'stop'}
+
+ try:
+ logger.info(f'🌤️ Stopping cloud browser session: {session_id}')
+
+ response = await self.client.patch(url, headers=headers, json=request_body)
+
+ if response.status_code == 401:
+ raise CloudBrowserAuthError(
+ 'Authentication failed. Please make sure you have set the BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
+ )
+ elif response.status_code == 404:
+ # Session already stopped or doesn't exist - treating as error and clearing session
+ logger.debug(f'🌤️ Cloud browser session {session_id} not found (already stopped)')
+ # Clear current session if it was this one
+ if session_id == self.current_session_id:
+ self.current_session_id = None
+ raise CloudBrowserError(f'Cloud browser session {session_id} not found')
+ elif not response.is_success:
+ error_msg = f'Failed to stop cloud browser: HTTP {response.status_code}'
+ try:
+ error_data = response.json()
+ if 'detail' in error_data:
+ error_msg += f' - {error_data["detail"]}'
+ except Exception:
+ pass
+ raise CloudBrowserError(error_msg)
+
+ browser_data = response.json()
+ browser_response = CloudBrowserResponse(**browser_data)
+
+ # Clear current session if it was this one
+ if session_id == self.current_session_id:
+ self.current_session_id = None
+
+ logger.info(f'🌤️ Cloud browser session stopped: {browser_response.id}')
+ logger.debug(f'🌤️ Status: {browser_response.status}')
+
+ return browser_response
+
+ except httpx.TimeoutException:
+ raise CloudBrowserError('Timeout while stopping cloud browser. Please try again.')
+ except httpx.ConnectError:
+ raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
+ except Exception as e:
+ if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
+ raise
+ raise CloudBrowserError(f'Unexpected error stopping cloud browser: {e}')
+
+ async def close(self):
+ """Close the HTTP client and cleanup any active sessions."""
+ # Try to stop current session if active
+ if self.current_session_id:
+ try:
+ await self.stop_browser()
+ except Exception as e:
+ logger.debug(f'Failed to stop cloud browser session during cleanup: {e}')
+
+ await self.client.aclose()
+
+
+# Global client instance
+_cloud_client: CloudBrowserClient | None = None
+
+
+async def get_cloud_browser_cdp_url() -> str:
+ """Get a CDP URL for a new cloud browser instance.
+
+ Returns:
+ str: CDP URL for connecting to the cloud browser
+
+ Raises:
+ CloudBrowserAuthError: If authentication fails
+ CloudBrowserError: If browser creation fails
+ """
+ global _cloud_client
+
+ if _cloud_client is None:
+ _cloud_client = CloudBrowserClient()
+
+ try:
+ browser_response = await _cloud_client.create_browser()
+ return browser_response.cdpUrl
+ except Exception:
+ # Clean up client on error
+ if _cloud_client:
+ await _cloud_client.close()
+ _cloud_client = None
+ raise
+
+
+async def stop_cloud_browser_session(session_id: str | None = None) -> CloudBrowserResponse:
+ """Stop a cloud browser session.
+
+ Args:
+ session_id: Session ID to stop. If None, uses current session from global client.
+
+ Returns:
+ CloudBrowserResponse: Updated browser info with stopped status
+
+ Raises:
+ CloudBrowserAuthError: If authentication fails
+ CloudBrowserError: If stopping fails
+ """
+ global _cloud_client
+
+ if _cloud_client is None:
+ _cloud_client = CloudBrowserClient()
+
+ try:
+ return await _cloud_client.stop_browser(session_id)
+ except Exception:
+ # Don't clean up client on stop errors - session might still be valid
+ raise
+
+
+async def cleanup_cloud_client():
+ """Clean up the global cloud client."""
+ global _cloud_client
+ if _cloud_client:
+ await _cloud_client.close()
+ _cloud_client = None
diff --git a/browser_use/browser/profile.py b/browser_use/browser/profile.py
index e227aefef..daf7494e8 100644
--- a/browser_use/browser/profile.py
+++ b/browser_use/browser/profile.py
@@ -1,3 +1,4 @@
+import os
import sys
import tempfile
from collections.abc import Iterable
@@ -550,7 +551,15 @@ class BrowserProfile(BrowserConnectArgs, BrowserLaunchPersistentContextArgs, Bro
# Session/connection configuration
cdp_url: str | None = Field(default=None, description='CDP URL for connecting to existing browser instance')
is_local: bool = Field(default=False, description='Whether this is a local browser instance')
- # label: str = 'default'
+ use_cloud: bool = Field(
+ default_factory=lambda: bool(os.getenv('BROWSER_USE_API_KEY')),
+ description='Use browser-use cloud browser service instead of local browser',
+ )
+
+ @property
+ def cloud_browser(self) -> bool:
+ """Alias for use_cloud field for compatibility."""
+ return self.use_cloud
# custom options we provide that aren't native playwright kwargs
disable_security: bool = Field(default=False, description='Disable browser security features.')
diff --git a/browser_use/browser/session.py b/browser_use/browser/session.py
index 6075448ad..4056929f9 100644
--- a/browser_use/browser/session.py
+++ b/browser_use/browser/session.py
@@ -15,6 +15,8 @@ from cdp_use.cdp.target import AttachedToTargetEvent, SessionID, TargetID
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from uuid_extensions import uuid7str
+from browser_use.browser.cloud import CloudBrowserAuthError, CloudBrowserError, get_cloud_browser_cdp_url
+
# CDP logging is now handled by setup_logging() in logging_config.py
# It automatically sets CDP logs to the same level as browser_use logs
from browser_use.browser.events import (
@@ -251,6 +253,8 @@ class BrowserSession(BaseModel):
# From BrowserNewContextArgs
storage_state: str | Path | dict[str, Any] | None = None,
# BrowserProfile specific fields
+ use_cloud: bool | None = None,
+ cloud_browser: bool | None = None, # Backward compatibility alias
disable_security: bool | None = None,
deterministic_rendering: bool | None = None,
allowed_domains: list[str] | None = None,
@@ -278,6 +282,10 @@ class BrowserSession(BaseModel):
# Only pass non-None values to avoid validation errors
profile_kwargs = {k: v for k, v in locals().items() if k not in ['self', 'browser_profile', 'id'] and v is not None}
+ # Handle backward compatibility: map cloud_browser to use_cloud
+ if 'cloud_browser' in profile_kwargs:
+ profile_kwargs['use_cloud'] = profile_kwargs.pop('cloud_browser')
+
# if is_local is False but executable_path is provided, set is_local to True
if is_local is False and executable_path is not None:
profile_kwargs['is_local'] = True
@@ -318,6 +326,11 @@ class BrowserSession(BaseModel):
"""Whether this is a local browser instance from browser profile."""
return self.browser_profile.is_local
+ @property
+ def cloud_browser(self) -> bool:
+ """Whether to use cloud browser service from browser profile."""
+ return self.browser_profile.use_cloud
+
# Main shared event bus for all browser session + all watchdogs
event_bus: EventBus = Field(default_factory=EventBus)
@@ -496,9 +509,22 @@ class BrowserSession(BaseModel):
await self.attach_all_watchdogs()
try:
- # If no CDP URL, launch local browser
+ # If no CDP URL, launch local browser or cloud browser
if not self.cdp_url:
- if self.is_local:
+ if self.browser_profile.use_cloud:
+ # Use cloud browser service
+ try:
+ cloud_cdp_url = await get_cloud_browser_cdp_url()
+ self.browser_profile.cdp_url = cloud_cdp_url
+ self.browser_profile.is_local = False
+ self.logger.info('🌤️ Successfully connected to cloud browser service')
+ except CloudBrowserAuthError:
+ raise CloudBrowserAuthError(
+ 'Authentication failed for cloud browser service. Set BROWSER_USE_API_KEY environment variable'
+ )
+ except CloudBrowserError as e:
+ raise CloudBrowserError(f'Failed to create cloud browser: {e}')
+ elif self.is_local:
# Launch local browser using event-driven approach
launch_event = self.event_bus.dispatch(BrowserLaunchEvent())
await launch_event
@@ -813,6 +839,16 @@ class BrowserSession(BaseModel):
self.event_bus.dispatch(BrowserStoppedEvent(reason='Kept alive due to keep_alive=True'))
return
+ # Clean up cloud browser session if using cloud browser
+ if self.browser_profile.use_cloud:
+ try:
+ from browser_use.browser.cloud import cleanup_cloud_client
+
+ await cleanup_cloud_client()
+ self.logger.info('🌤️ Cloud browser session cleaned up')
+ except Exception as e:
+ self.logger.debug(f'Failed to cleanup cloud browser session: {e}')
+
# Clear CDP session cache before stopping
await self.reset()
@@ -1616,18 +1652,18 @@ class BrowserSession(BaseModel):
const highlights = document.querySelectorAll('[data-browser-use-highlight]');
console.log('Removing', highlights.length, 'browser-use highlight elements');
highlights.forEach(el => el.remove());
-
+
// Also remove by ID in case selector missed anything
const highlightContainer = document.getElementById('browser-use-debug-highlights');
if (highlightContainer) {
console.log('Removing highlight container by ID');
highlightContainer.remove();
}
-
+
// Final cleanup - remove any orphaned tooltips
const orphanedTooltips = document.querySelectorAll('[data-browser-use-highlight="tooltip"]');
orphanedTooltips.forEach(el => el.remove());
-
+
return { removed: highlights.length };
})();
"""
diff --git a/browser_use/config.py b/browser_use/config.py
index e2a3b4194..02c13136e 100644
--- a/browser_use/config.py
+++ b/browser_use/config.py
@@ -287,7 +287,7 @@ def create_default_config() -> DBStyleConfigJSON:
new_config.browser_profile[profile_id] = BrowserProfileEntry(id=profile_id, default=True, headless=False, user_data_dir=None)
# Create default LLM entry
- new_config.llm[llm_id] = LLMEntry(id=llm_id, default=True, model='gpt-4o', api_key='your-openai-api-key-here')
+ new_config.llm[llm_id] = LLMEntry(id=llm_id, default=True, model='gpt-4.1-mini', api_key='your-openai-api-key-here')
# Create default agent entry
new_config.agent[agent_id] = AgentEntry(id=agent_id, default=True)
diff --git a/browser_use/dom/playground/extraction.py b/browser_use/dom/playground/extraction.py
index b526dc749..b1d4f9225 100644
--- a/browser_use/dom/playground/extraction.py
+++ b/browser_use/dom/playground/extraction.py
@@ -176,7 +176,7 @@ async def test_focus_vs_all_elements():
# copy the user message to the clipboard
# pyperclip.copy(text_to_save)
- encoding = tiktoken.encoding_for_model('gpt-4o')
+ encoding = tiktoken.encoding_for_model('gpt-4.1-mini')
token_count = len(encoding.encode(text_to_save))
print(f'Token count: {token_count}')
diff --git a/browser_use/dom/serializer/serializer.py b/browser_use/dom/serializer/serializer.py
index cd8ec1ab7..1a515379d 100644
--- a/browser_use/dom/serializer/serializer.py
+++ b/browser_use/dom/serializer/serializer.py
@@ -472,7 +472,7 @@ class DOMTreeSerializer:
if has_validation_attrs:
is_visible = True # Force visibility for validation elements
- # Include if visible, interactive, scrollable, has children, or is shadow host
+ # Include if visible, scrollable, has children, or is shadow host
if is_visible or is_scrollable or has_shadow_content or is_shadow_host:
simplified = SimplifiedNode(original_node=node, children=[], is_shadow_host=is_shadow_host)
diff --git a/docs/customize/browser/remote.mdx b/docs/customize/browser/remote.mdx
index 2cef0807e..aa21115b9 100644
--- a/docs/customize/browser/remote.mdx
+++ b/docs/customize/browser/remote.mdx
@@ -23,8 +23,39 @@ agent = Agent(
## Get a CDP URL
-### Cloud Browser
-Get a cdp url from your favorite browser provider like AnchorBorwser, HyperBrowser, BrowserBase, Steel.dev, etc.
+
+### Browser-Use Cloud Browser
+
+The easiest way to use a cloud browser is with the built-in Browser-Use cloud service:
+
+```python
+from browser_use import Agent, Browser, ChatOpenAI
+
+# Use Browser-Use cloud browser service
+browser = Browser(
+ cloud_browser=True # Automatically provisions a cloud browser
+)
+
+agent = Agent(
+ task="Your task here",
+ llm=ChatOpenAI(model='gpt-4.1-mini'),
+ browser=browser,
+)
+```
+
+**Prerequisites:**
+1. Get an API key from [cloud.browser-use.com](https://cloud.browser-use.com)
+2. Set BROWSER_USE_API_KEY environment variable
+
+**Benefits:**
+- ✅ No local browser setup required
+- ✅ Scalable and fast cloud infrastructure
+- ✅ Automatic provisioning and teardown
+- ✅ Built-in authentication handling
+- ✅ Optimized for browser automation
+
+### Third-Party Cloud Browsers
+Get a CDP URL from your favorite browser provider like AnchorBrowser, HyperBrowser, BrowserBase, Steel.dev, etc.
diff --git a/examples/browser/cloud_browser.py b/examples/browser/cloud_browser.py
new file mode 100644
index 000000000..15d397690
--- /dev/null
+++ b/examples/browser/cloud_browser.py
@@ -0,0 +1,41 @@
+"""
+Simple example of using Browser-Use cloud browser service.
+
+Prerequisites:
+1. Set BROWSER_USE_API_KEY environment variable
+2. Active subscription at https://cloud.browser-use.com
+"""
+
+import asyncio
+
+from dotenv import load_dotenv
+
+# Load environment variables
+load_dotenv()
+
+from browser_use import Agent, Browser, ChatOpenAI
+
+
+async def main():
+ """Basic cloud browser example."""
+
+ print('🌤️ Using Browser-Use Cloud Browser')
+
+ # Create agent with cloud browser enabled
+ agent = Agent(
+ task='Go to https://github.com/browser-use/browser-use and find the number of stars',
+ llm=ChatOpenAI(model='gpt-4.1-mini'),
+ browser=Browser(use_cloud=True), # Enable cloud browser
+ )
+
+ try:
+ result = await agent.run()
+ print(f'✅ Result: {result}')
+ except Exception as e:
+ print(f'❌ Error: {e}')
+ if 'Authentication' in str(e):
+ print('💡 Set BROWSER_USE_API_KEY environment variable')
+
+
+if __name__ == '__main__':
+ asyncio.run(main())
diff --git a/tests/ci/test_cloud_browser.py b/tests/ci/test_cloud_browser.py
new file mode 100644
index 000000000..fa66d7ade
--- /dev/null
+++ b/tests/ci/test_cloud_browser.py
@@ -0,0 +1,333 @@
+"""Tests for cloud browser functionality."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from browser_use.browser.cloud import (
+ CloudBrowserAuthError,
+ CloudBrowserClient,
+ CloudBrowserError,
+ get_cloud_browser_cdp_url,
+ stop_cloud_browser_session,
+)
+from browser_use.browser.profile import BrowserProfile
+from browser_use.browser.session import BrowserSession
+from browser_use.sync.auth import CloudAuthConfig
+
+
+@pytest.fixture
+def temp_config_dir(monkeypatch):
+ """Create temporary config directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ temp_dir = Path(tmpdir) / '.config' / 'browseruse'
+ temp_dir.mkdir(parents=True, exist_ok=True)
+
+ # Use monkeypatch to set the environment variable
+ monkeypatch.setenv('BROWSER_USE_CONFIG_DIR', str(temp_dir))
+
+ yield temp_dir
+
+
+@pytest.fixture
+def mock_auth_config(temp_config_dir):
+ """Create a mock auth config with valid token."""
+ auth_config = CloudAuthConfig(api_token='test-token', user_id='test-user-id', authorized_at=None)
+ auth_config.save_to_file()
+ return auth_config
+
+
+class TestCloudBrowserClient:
+ """Test CloudBrowserClient class."""
+
+ async def test_create_browser_success(self, mock_auth_config):
+ """Test successful cloud browser creation."""
+
+ # Mock response data matching the API
+ mock_response_data = {
+ 'id': 'test-browser-id',
+ 'status': 'active',
+ 'liveUrl': 'https://live.browser-use.com?wss=test',
+ 'cdpUrl': 'wss://test.proxy.daytona.works',
+ 'timeoutAt': '2025-09-17T04:35:36.049892',
+ 'startedAt': '2025-09-17T03:35:36.049974',
+ 'finishedAt': None,
+ }
+
+ # Mock the httpx client
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 201
+ mock_response.is_success = True
+ mock_response.json = lambda: mock_response_data
+
+ mock_client = AsyncMock()
+ mock_client.post.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ client = CloudBrowserClient()
+ client.client = mock_client
+
+ result = await client.create_browser()
+
+ assert result.id == 'test-browser-id'
+ assert result.status == 'active'
+ assert result.cdpUrl == 'wss://test.proxy.daytona.works'
+
+ # Verify auth headers were included
+ mock_client.post.assert_called_once()
+ call_args = mock_client.post.call_args
+ assert 'X-Browser-Use-API-Key' in call_args.kwargs['headers']
+ assert call_args.kwargs['headers']['X-Browser-Use-API-Key'] == 'test-token'
+
+ async def test_create_browser_auth_error(self, temp_config_dir):
+ """Test cloud browser creation with auth error."""
+
+ # Don't create auth config - should trigger auth error
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_client = AsyncMock()
+ mock_client_class.return_value = mock_client
+
+ client = CloudBrowserClient()
+ client.client = mock_client
+
+ with pytest.raises(CloudBrowserAuthError) as exc_info:
+ await client.create_browser()
+
+ assert 'BROWSER_USE_API_KEY environment variable' in str(exc_info.value)
+
+ async def test_create_browser_http_401(self, mock_auth_config):
+ """Test cloud browser creation with HTTP 401 response."""
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 401
+ mock_response.is_success = False
+
+ mock_client = AsyncMock()
+ mock_client.post.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ client = CloudBrowserClient()
+ client.client = mock_client
+
+ with pytest.raises(CloudBrowserAuthError) as exc_info:
+ await client.create_browser()
+
+ assert 'Authentication failed' in str(exc_info.value)
+
+ async def test_create_browser_with_env_var(self, temp_config_dir, monkeypatch):
+ """Test cloud browser creation using BROWSER_USE_API_KEY environment variable."""
+
+ # Set environment variable
+ monkeypatch.setenv('BROWSER_USE_API_KEY', 'env-test-token')
+
+ # Mock response data matching the API
+ mock_response_data = {
+ 'id': 'test-browser-id',
+ 'status': 'active',
+ 'liveUrl': 'https://live.browser-use.com?wss=test',
+ 'cdpUrl': 'wss://test.proxy.daytona.works',
+ 'timeoutAt': '2025-09-17T04:35:36.049892',
+ 'startedAt': '2025-09-17T03:35:36.049974',
+ 'finishedAt': None,
+ }
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 201
+ mock_response.is_success = True
+ mock_response.json = lambda: mock_response_data
+
+ mock_client = AsyncMock()
+ mock_client.post.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ client = CloudBrowserClient()
+ client.client = mock_client
+
+ result = await client.create_browser()
+
+ assert result.id == 'test-browser-id'
+ assert result.status == 'active'
+ assert result.cdpUrl == 'wss://test.proxy.daytona.works'
+
+ # Verify environment variable was used
+ mock_client.post.assert_called_once()
+ call_args = mock_client.post.call_args
+ assert 'X-Browser-Use-API-Key' in call_args.kwargs['headers']
+ assert call_args.kwargs['headers']['X-Browser-Use-API-Key'] == 'env-test-token'
+
+ async def test_stop_browser_success(self, mock_auth_config):
+ """Test successful cloud browser session stop."""
+
+ # Mock response data for stop
+ mock_response_data = {
+ 'id': 'test-browser-id',
+ 'status': 'stopped',
+ 'liveUrl': 'https://live.browser-use.com?wss=test',
+ 'cdpUrl': 'wss://test.proxy.daytona.works',
+ 'timeoutAt': '2025-09-17T04:35:36.049892',
+ 'startedAt': '2025-09-17T03:35:36.049974',
+ 'finishedAt': '2025-09-17T04:35:36.049892',
+ }
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 200
+ mock_response.is_success = True
+ mock_response.json = lambda: mock_response_data
+
+ mock_client = AsyncMock()
+ mock_client.patch.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ client = CloudBrowserClient()
+ client.client = mock_client
+ client.current_session_id = 'test-browser-id'
+
+ result = await client.stop_browser()
+
+ assert result.id == 'test-browser-id'
+ assert result.status == 'stopped'
+ assert result.finishedAt is not None
+
+ # Verify correct API call
+ mock_client.patch.assert_called_once()
+ call_args = mock_client.patch.call_args
+ assert 'test-browser-id' in call_args.args[0] # URL contains session ID
+ assert call_args.kwargs['json'] == {'action': 'stop'}
+ assert 'X-Browser-Use-API-Key' in call_args.kwargs['headers']
+
+ async def test_stop_browser_session_not_found(self, mock_auth_config):
+ """Test stopping a browser session that doesn't exist."""
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 404
+ mock_response.is_success = False
+
+ mock_client = AsyncMock()
+ mock_client.patch.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ client = CloudBrowserClient()
+ client.client = mock_client
+
+ with pytest.raises(CloudBrowserError) as exc_info:
+ await client.stop_browser('nonexistent-session')
+
+ assert 'not found' in str(exc_info.value)
+
+
+class TestBrowserSessionCloudIntegration:
+ """Test BrowserSession integration with cloud browsers."""
+
+ async def test_cloud_browser_profile_property(self):
+ """Test that cloud_browser property works correctly."""
+
+ profile = BrowserProfile(use_cloud=True)
+ session = BrowserSession(browser_profile=profile)
+
+ assert session.cloud_browser is True
+ assert session.browser_profile.use_cloud is True
+
+ async def test_browser_session_cloud_browser_logic(self, mock_auth_config):
+ """Test that cloud browser profile settings work correctly."""
+
+ # Test cloud browser profile creation
+ profile = BrowserProfile(use_cloud=True)
+ assert profile.use_cloud is True
+
+ # Test that BrowserSession respects cloud_browser setting
+ session = BrowserSession(browser_profile=profile)
+ assert session.cloud_browser is True
+
+ # Test that get_cloud_browser_cdp_url works with mocked API
+ with patch('browser_use.browser.cloud.get_cloud_browser_cdp_url') as mock_get_cdp_url:
+ mock_get_cdp_url.return_value = 'wss://test.proxy.daytona.works'
+
+ cdp_url = await mock_get_cdp_url()
+ assert cdp_url == 'wss://test.proxy.daytona.works'
+ mock_get_cdp_url.assert_called_once()
+
+
+async def test_get_cloud_browser_cdp_url_function(mock_auth_config):
+ """Test the get_cloud_browser_cdp_url convenience function."""
+
+ mock_response_data = {
+ 'id': 'test-browser-id',
+ 'status': 'active',
+ 'liveUrl': 'https://live.browser-use.com?wss=test',
+ 'cdpUrl': 'wss://test.proxy.daytona.works',
+ 'timeoutAt': '2025-09-17T04:35:36.049892',
+ 'startedAt': '2025-09-17T03:35:36.049974',
+ 'finishedAt': None,
+ }
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 201
+ mock_response.is_success = True
+ mock_response.json = lambda: mock_response_data
+
+ mock_client = AsyncMock()
+ mock_client.post.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ cdp_url = await get_cloud_browser_cdp_url()
+
+ assert cdp_url == 'wss://test.proxy.daytona.works'
+
+
+async def test_cloud_browser_auth_error_no_fallback(temp_config_dir):
+ """Test that cloud browser throws error when auth fails (no fallback)."""
+
+ # Don't create auth config to trigger auth error
+ profile = BrowserProfile(use_cloud=True)
+
+ # Test that cloud browser client raises error without fallback
+ with patch('browser_use.browser.cloud.get_cloud_browser_cdp_url') as mock_cloud_cdp:
+ mock_cloud_cdp.side_effect = CloudBrowserAuthError('No auth token')
+
+ # Verify that the cloud browser client raises the expected error
+ with pytest.raises(CloudBrowserAuthError) as exc_info:
+ await get_cloud_browser_cdp_url()
+
+ assert 'BROWSER_USE_API_KEY environment variable' in str(exc_info.value)
+
+ # Verify profile state unchanged (no fallback)
+ assert profile.use_cloud is True
+ assert profile.is_local is False
+
+
+async def test_stop_cloud_browser_session_function(mock_auth_config):
+ """Test the stop_cloud_browser_session convenience function."""
+
+ mock_response_data = {
+ 'id': 'test-browser-id',
+ 'status': 'stopped',
+ 'liveUrl': 'https://live.browser-use.com?wss=test',
+ 'cdpUrl': 'wss://test.proxy.daytona.works',
+ 'timeoutAt': '2025-09-17T04:35:36.049892',
+ 'startedAt': '2025-09-17T03:35:36.049974',
+ 'finishedAt': '2025-09-17T04:35:36.049892',
+ }
+
+ with patch('httpx.AsyncClient') as mock_client_class:
+ mock_response = AsyncMock()
+ mock_response.status_code = 200
+ mock_response.is_success = True
+ mock_response.json = lambda: mock_response_data
+
+ mock_client = AsyncMock()
+ mock_client.patch.return_value = mock_response
+ mock_client_class.return_value = mock_client
+
+ result = await stop_cloud_browser_session('test-browser-id')
+
+ assert result.id == 'test-browser-id'
+ assert result.status == 'stopped'