optexity-browser-use 0.9.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_use/__init__.py +157 -0
- browser_use/actor/__init__.py +11 -0
- browser_use/actor/element.py +1175 -0
- browser_use/actor/mouse.py +134 -0
- browser_use/actor/page.py +561 -0
- browser_use/actor/playground/flights.py +41 -0
- browser_use/actor/playground/mixed_automation.py +54 -0
- browser_use/actor/playground/playground.py +236 -0
- browser_use/actor/utils.py +176 -0
- browser_use/agent/cloud_events.py +282 -0
- browser_use/agent/gif.py +424 -0
- browser_use/agent/judge.py +170 -0
- browser_use/agent/message_manager/service.py +473 -0
- browser_use/agent/message_manager/utils.py +52 -0
- browser_use/agent/message_manager/views.py +98 -0
- browser_use/agent/prompts.py +413 -0
- browser_use/agent/service.py +2316 -0
- browser_use/agent/system_prompt.md +185 -0
- browser_use/agent/system_prompt_flash.md +10 -0
- browser_use/agent/system_prompt_no_thinking.md +183 -0
- browser_use/agent/views.py +743 -0
- browser_use/browser/__init__.py +41 -0
- browser_use/browser/cloud/cloud.py +203 -0
- browser_use/browser/cloud/views.py +89 -0
- browser_use/browser/events.py +578 -0
- browser_use/browser/profile.py +1158 -0
- browser_use/browser/python_highlights.py +548 -0
- browser_use/browser/session.py +3225 -0
- browser_use/browser/session_manager.py +399 -0
- browser_use/browser/video_recorder.py +162 -0
- browser_use/browser/views.py +200 -0
- browser_use/browser/watchdog_base.py +260 -0
- browser_use/browser/watchdogs/__init__.py +0 -0
- browser_use/browser/watchdogs/aboutblank_watchdog.py +253 -0
- browser_use/browser/watchdogs/crash_watchdog.py +335 -0
- browser_use/browser/watchdogs/default_action_watchdog.py +2729 -0
- browser_use/browser/watchdogs/dom_watchdog.py +817 -0
- browser_use/browser/watchdogs/downloads_watchdog.py +1277 -0
- browser_use/browser/watchdogs/local_browser_watchdog.py +461 -0
- browser_use/browser/watchdogs/permissions_watchdog.py +43 -0
- browser_use/browser/watchdogs/popups_watchdog.py +143 -0
- browser_use/browser/watchdogs/recording_watchdog.py +126 -0
- browser_use/browser/watchdogs/screenshot_watchdog.py +62 -0
- browser_use/browser/watchdogs/security_watchdog.py +280 -0
- browser_use/browser/watchdogs/storage_state_watchdog.py +335 -0
- browser_use/cli.py +2359 -0
- browser_use/code_use/__init__.py +16 -0
- browser_use/code_use/formatting.py +192 -0
- browser_use/code_use/namespace.py +665 -0
- browser_use/code_use/notebook_export.py +276 -0
- browser_use/code_use/service.py +1340 -0
- browser_use/code_use/system_prompt.md +574 -0
- browser_use/code_use/utils.py +150 -0
- browser_use/code_use/views.py +171 -0
- browser_use/config.py +505 -0
- browser_use/controller/__init__.py +3 -0
- browser_use/dom/enhanced_snapshot.py +161 -0
- browser_use/dom/markdown_extractor.py +169 -0
- browser_use/dom/playground/extraction.py +312 -0
- browser_use/dom/playground/multi_act.py +32 -0
- browser_use/dom/serializer/clickable_elements.py +200 -0
- browser_use/dom/serializer/code_use_serializer.py +287 -0
- browser_use/dom/serializer/eval_serializer.py +478 -0
- browser_use/dom/serializer/html_serializer.py +212 -0
- browser_use/dom/serializer/paint_order.py +197 -0
- browser_use/dom/serializer/serializer.py +1170 -0
- browser_use/dom/service.py +825 -0
- browser_use/dom/utils.py +129 -0
- browser_use/dom/views.py +906 -0
- browser_use/exceptions.py +5 -0
- browser_use/filesystem/__init__.py +0 -0
- browser_use/filesystem/file_system.py +619 -0
- browser_use/init_cmd.py +376 -0
- browser_use/integrations/gmail/__init__.py +24 -0
- browser_use/integrations/gmail/actions.py +115 -0
- browser_use/integrations/gmail/service.py +225 -0
- browser_use/llm/__init__.py +155 -0
- browser_use/llm/anthropic/chat.py +242 -0
- browser_use/llm/anthropic/serializer.py +312 -0
- browser_use/llm/aws/__init__.py +36 -0
- browser_use/llm/aws/chat_anthropic.py +242 -0
- browser_use/llm/aws/chat_bedrock.py +289 -0
- browser_use/llm/aws/serializer.py +257 -0
- browser_use/llm/azure/chat.py +91 -0
- browser_use/llm/base.py +57 -0
- browser_use/llm/browser_use/__init__.py +3 -0
- browser_use/llm/browser_use/chat.py +201 -0
- browser_use/llm/cerebras/chat.py +193 -0
- browser_use/llm/cerebras/serializer.py +109 -0
- browser_use/llm/deepseek/chat.py +212 -0
- browser_use/llm/deepseek/serializer.py +109 -0
- browser_use/llm/exceptions.py +29 -0
- browser_use/llm/google/__init__.py +3 -0
- browser_use/llm/google/chat.py +542 -0
- browser_use/llm/google/serializer.py +120 -0
- browser_use/llm/groq/chat.py +229 -0
- browser_use/llm/groq/parser.py +158 -0
- browser_use/llm/groq/serializer.py +159 -0
- browser_use/llm/messages.py +238 -0
- browser_use/llm/models.py +271 -0
- browser_use/llm/oci_raw/__init__.py +10 -0
- browser_use/llm/oci_raw/chat.py +443 -0
- browser_use/llm/oci_raw/serializer.py +229 -0
- browser_use/llm/ollama/chat.py +97 -0
- browser_use/llm/ollama/serializer.py +143 -0
- browser_use/llm/openai/chat.py +264 -0
- browser_use/llm/openai/like.py +15 -0
- browser_use/llm/openai/serializer.py +165 -0
- browser_use/llm/openrouter/chat.py +211 -0
- browser_use/llm/openrouter/serializer.py +26 -0
- browser_use/llm/schema.py +176 -0
- browser_use/llm/views.py +48 -0
- browser_use/logging_config.py +330 -0
- browser_use/mcp/__init__.py +18 -0
- browser_use/mcp/__main__.py +12 -0
- browser_use/mcp/client.py +544 -0
- browser_use/mcp/controller.py +264 -0
- browser_use/mcp/server.py +1114 -0
- browser_use/observability.py +204 -0
- browser_use/py.typed +0 -0
- browser_use/sandbox/__init__.py +41 -0
- browser_use/sandbox/sandbox.py +637 -0
- browser_use/sandbox/views.py +132 -0
- browser_use/screenshots/__init__.py +1 -0
- browser_use/screenshots/service.py +52 -0
- browser_use/sync/__init__.py +6 -0
- browser_use/sync/auth.py +357 -0
- browser_use/sync/service.py +161 -0
- browser_use/telemetry/__init__.py +51 -0
- browser_use/telemetry/service.py +112 -0
- browser_use/telemetry/views.py +101 -0
- browser_use/tokens/__init__.py +0 -0
- browser_use/tokens/custom_pricing.py +24 -0
- browser_use/tokens/mappings.py +4 -0
- browser_use/tokens/service.py +580 -0
- browser_use/tokens/views.py +108 -0
- browser_use/tools/registry/service.py +572 -0
- browser_use/tools/registry/views.py +174 -0
- browser_use/tools/service.py +1675 -0
- browser_use/tools/utils.py +82 -0
- browser_use/tools/views.py +100 -0
- browser_use/utils.py +670 -0
- optexity_browser_use-0.9.5.dist-info/METADATA +344 -0
- optexity_browser_use-0.9.5.dist-info/RECORD +147 -0
- optexity_browser_use-0.9.5.dist-info/WHEEL +4 -0
- optexity_browser_use-0.9.5.dist-info/entry_points.txt +3 -0
- optexity_browser_use-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Type-safe event models for sandbox execution SSE streaming"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SandboxError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SSEEventType(str, Enum):
|
|
15
|
+
"""Event types for Server-Sent Events"""
|
|
16
|
+
|
|
17
|
+
BROWSER_CREATED = 'browser_created'
|
|
18
|
+
INSTANCE_CREATED = 'instance_created'
|
|
19
|
+
INSTANCE_READY = 'instance_ready'
|
|
20
|
+
LOG = 'log'
|
|
21
|
+
RESULT = 'result'
|
|
22
|
+
ERROR = 'error'
|
|
23
|
+
STREAM_COMPLETE = 'stream_complete'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BrowserCreatedData(BaseModel):
|
|
27
|
+
"""Data for browser_created event"""
|
|
28
|
+
|
|
29
|
+
session_id: str
|
|
30
|
+
live_url: str
|
|
31
|
+
status: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LogData(BaseModel):
|
|
35
|
+
"""Data for log event"""
|
|
36
|
+
|
|
37
|
+
message: str
|
|
38
|
+
level: str = 'info' # stdout, stderr, info, warning, error
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecutionResponse(BaseModel):
|
|
42
|
+
"""Execution result from the executor"""
|
|
43
|
+
|
|
44
|
+
success: bool
|
|
45
|
+
result: Any = None
|
|
46
|
+
error: str | None = None
|
|
47
|
+
traceback: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ResultData(BaseModel):
|
|
51
|
+
"""Data for result event"""
|
|
52
|
+
|
|
53
|
+
execution_response: ExecutionResponse
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ErrorData(BaseModel):
|
|
57
|
+
"""Data for error event"""
|
|
58
|
+
|
|
59
|
+
error: str
|
|
60
|
+
traceback: str | None = None
|
|
61
|
+
status_code: int = 500
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SSEEvent(BaseModel):
|
|
65
|
+
"""Type-safe SSE Event
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
# Parse from JSON
|
|
69
|
+
event = SSEEvent.from_json(event_json_string)
|
|
70
|
+
|
|
71
|
+
# Type-safe access with type guards
|
|
72
|
+
if event.is_browser_created():
|
|
73
|
+
assert isinstance(event.data, BrowserCreatedData)
|
|
74
|
+
print(event.data.live_url)
|
|
75
|
+
|
|
76
|
+
# Or check event type directly
|
|
77
|
+
if event.type == SSEEventType.LOG:
|
|
78
|
+
assert isinstance(event.data, LogData)
|
|
79
|
+
print(event.data.message)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
type: SSEEventType
|
|
83
|
+
data: BrowserCreatedData | LogData | ResultData | ErrorData | dict[str, Any]
|
|
84
|
+
timestamp: str | None = None
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_json(cls, event_json: str) -> 'SSEEvent':
|
|
88
|
+
"""Parse SSE event from JSON string with proper type discrimination
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
event_json: JSON string from SSE stream
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Typed SSEEvent with appropriate data model
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
json.JSONDecodeError: If JSON is malformed
|
|
98
|
+
ValueError: If event type is invalid
|
|
99
|
+
"""
|
|
100
|
+
raw_data = json.loads(event_json)
|
|
101
|
+
event_type = SSEEventType(raw_data.get('type'))
|
|
102
|
+
data_dict = raw_data.get('data', {})
|
|
103
|
+
|
|
104
|
+
# Parse data based on event type
|
|
105
|
+
if event_type == SSEEventType.BROWSER_CREATED:
|
|
106
|
+
data = BrowserCreatedData(**data_dict)
|
|
107
|
+
elif event_type == SSEEventType.LOG:
|
|
108
|
+
data = LogData(**data_dict)
|
|
109
|
+
elif event_type == SSEEventType.RESULT:
|
|
110
|
+
data = ResultData(**data_dict)
|
|
111
|
+
elif event_type == SSEEventType.ERROR:
|
|
112
|
+
data = ErrorData(**data_dict)
|
|
113
|
+
else:
|
|
114
|
+
data = data_dict
|
|
115
|
+
|
|
116
|
+
return cls(type=event_type, data=data, timestamp=raw_data.get('timestamp'))
|
|
117
|
+
|
|
118
|
+
def is_browser_created(self) -> bool:
|
|
119
|
+
"""Type guard for BrowserCreatedData"""
|
|
120
|
+
return self.type == SSEEventType.BROWSER_CREATED and isinstance(self.data, BrowserCreatedData)
|
|
121
|
+
|
|
122
|
+
def is_log(self) -> bool:
|
|
123
|
+
"""Type guard for LogData"""
|
|
124
|
+
return self.type == SSEEventType.LOG and isinstance(self.data, LogData)
|
|
125
|
+
|
|
126
|
+
def is_result(self) -> bool:
|
|
127
|
+
"""Type guard for ResultData"""
|
|
128
|
+
return self.type == SSEEventType.RESULT and isinstance(self.data, ResultData)
|
|
129
|
+
|
|
130
|
+
def is_error(self) -> bool:
|
|
131
|
+
"""Type guard for ErrorData"""
|
|
132
|
+
return self.type == SSEEventType.ERROR and isinstance(self.data, ErrorData)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Screenshots package for browser-use
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Screenshot storage service for browser-use agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
|
|
10
|
+
from browser_use.observability import observe_debug
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScreenshotService:
|
|
14
|
+
"""Simple screenshot storage service that saves screenshots to disk"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, agent_directory: str | Path):
|
|
17
|
+
"""Initialize with agent directory path"""
|
|
18
|
+
self.agent_directory = Path(agent_directory) if isinstance(agent_directory, str) else agent_directory
|
|
19
|
+
|
|
20
|
+
# Create screenshots subdirectory
|
|
21
|
+
self.screenshots_dir = self.agent_directory / 'screenshots'
|
|
22
|
+
self.screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
@observe_debug(ignore_input=True, ignore_output=True, name='store_screenshot')
|
|
25
|
+
async def store_screenshot(self, screenshot_b64: str, step_number: int) -> str:
|
|
26
|
+
"""Store screenshot to disk and return the full path as string"""
|
|
27
|
+
screenshot_filename = f'step_{step_number}.png'
|
|
28
|
+
screenshot_path = self.screenshots_dir / screenshot_filename
|
|
29
|
+
|
|
30
|
+
# Decode base64 and save to disk
|
|
31
|
+
screenshot_data = base64.b64decode(screenshot_b64)
|
|
32
|
+
|
|
33
|
+
async with await anyio.open_file(screenshot_path, 'wb') as f:
|
|
34
|
+
await f.write(screenshot_data)
|
|
35
|
+
|
|
36
|
+
return str(screenshot_path)
|
|
37
|
+
|
|
38
|
+
@observe_debug(ignore_input=True, ignore_output=True, name='get_screenshot_from_disk')
|
|
39
|
+
async def get_screenshot(self, screenshot_path: str) -> str | None:
|
|
40
|
+
"""Load screenshot from disk path and return as base64"""
|
|
41
|
+
if not screenshot_path:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
path = Path(screenshot_path)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Load from disk and encode to base64
|
|
49
|
+
async with await anyio.open_file(path, 'rb') as f:
|
|
50
|
+
screenshot_data = await f.read()
|
|
51
|
+
|
|
52
|
+
return base64.b64encode(screenshot_data).decode('utf-8')
|
browser_use/sync/auth.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth2 Device Authorization Grant flow client for browser-use.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from uuid_extensions import uuid7str
|
|
15
|
+
|
|
16
|
+
from browser_use.config import CONFIG
|
|
17
|
+
|
|
18
|
+
# Temporary user ID for pre-auth events (matches cloud backend)
|
|
19
|
+
TEMP_USER_ID = '99999999-9999-9999-9999-999999999999'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_or_create_device_id() -> str:
|
|
23
|
+
"""Get or create a persistent device ID for this installation."""
|
|
24
|
+
device_id_path = CONFIG.BROWSER_USE_CONFIG_DIR / 'device_id'
|
|
25
|
+
|
|
26
|
+
# Try to read existing device ID
|
|
27
|
+
if device_id_path.exists():
|
|
28
|
+
try:
|
|
29
|
+
device_id = device_id_path.read_text().strip()
|
|
30
|
+
if device_id: # Make sure it's not empty
|
|
31
|
+
return device_id
|
|
32
|
+
except Exception:
|
|
33
|
+
# If we can't read it, we'll create a new one
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
# Create new device ID
|
|
37
|
+
device_id = uuid7str()
|
|
38
|
+
|
|
39
|
+
# Ensure config directory exists
|
|
40
|
+
CONFIG.BROWSER_USE_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
# Write device ID to file
|
|
43
|
+
device_id_path.write_text(device_id)
|
|
44
|
+
|
|
45
|
+
return device_id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CloudAuthConfig(BaseModel):
|
|
49
|
+
"""Configuration for cloud authentication"""
|
|
50
|
+
|
|
51
|
+
api_token: str | None = None
|
|
52
|
+
user_id: str | None = None
|
|
53
|
+
authorized_at: datetime | None = None
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def load_from_file(cls) -> 'CloudAuthConfig':
|
|
57
|
+
"""Load auth config from local file"""
|
|
58
|
+
|
|
59
|
+
config_path = CONFIG.BROWSER_USE_CONFIG_DIR / 'cloud_auth.json'
|
|
60
|
+
if config_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
with open(config_path) as f:
|
|
63
|
+
data = json.load(f)
|
|
64
|
+
return cls.model_validate(data)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Return empty config if file is corrupted
|
|
67
|
+
pass
|
|
68
|
+
return cls()
|
|
69
|
+
|
|
70
|
+
def save_to_file(self) -> None:
|
|
71
|
+
"""Save auth config to local file"""
|
|
72
|
+
|
|
73
|
+
CONFIG.BROWSER_USE_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
config_path = CONFIG.BROWSER_USE_CONFIG_DIR / 'cloud_auth.json'
|
|
76
|
+
with open(config_path, 'w') as f:
|
|
77
|
+
json.dump(self.model_dump(mode='json'), f, indent=2, default=str)
|
|
78
|
+
|
|
79
|
+
# Set restrictive permissions (owner read/write only) for security
|
|
80
|
+
try:
|
|
81
|
+
os.chmod(config_path, 0o600)
|
|
82
|
+
except Exception:
|
|
83
|
+
# Some systems may not support chmod, continue anyway
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DeviceAuthClient:
|
|
88
|
+
"""Client for OAuth2 device authorization flow"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, base_url: str | None = None, http_client: httpx.AsyncClient | None = None):
|
|
91
|
+
# Backend API URL for OAuth requests - can be passed directly or defaults to env var
|
|
92
|
+
self.base_url = base_url or CONFIG.BROWSER_USE_CLOUD_API_URL
|
|
93
|
+
self.client_id = 'library'
|
|
94
|
+
self.scope = 'read write'
|
|
95
|
+
|
|
96
|
+
# If no client provided, we'll create one per request
|
|
97
|
+
self.http_client = http_client
|
|
98
|
+
|
|
99
|
+
# Temporary user ID for pre-auth events
|
|
100
|
+
self.temp_user_id = TEMP_USER_ID
|
|
101
|
+
|
|
102
|
+
# Get or create persistent device ID
|
|
103
|
+
self.device_id = get_or_create_device_id()
|
|
104
|
+
|
|
105
|
+
# Load existing auth if available
|
|
106
|
+
self.auth_config = CloudAuthConfig.load_from_file()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_authenticated(self) -> bool:
|
|
110
|
+
"""Check if we have valid authentication"""
|
|
111
|
+
return bool(self.auth_config.api_token and self.auth_config.user_id)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def api_token(self) -> str | None:
|
|
115
|
+
"""Get the current API token"""
|
|
116
|
+
return self.auth_config.api_token
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def user_id(self) -> str:
|
|
120
|
+
"""Get the current user ID (temporary or real)"""
|
|
121
|
+
return self.auth_config.user_id or self.temp_user_id
|
|
122
|
+
|
|
123
|
+
async def start_device_authorization(
|
|
124
|
+
self,
|
|
125
|
+
agent_session_id: str | None = None,
|
|
126
|
+
) -> dict:
|
|
127
|
+
"""
|
|
128
|
+
Start the device authorization flow.
|
|
129
|
+
Returns device authorization details including user code and verification URL.
|
|
130
|
+
"""
|
|
131
|
+
if self.http_client:
|
|
132
|
+
response = await self.http_client.post(
|
|
133
|
+
f'{self.base_url.rstrip("/")}/api/v1/oauth/device/authorize',
|
|
134
|
+
data={
|
|
135
|
+
'client_id': self.client_id,
|
|
136
|
+
'scope': self.scope,
|
|
137
|
+
'agent_session_id': agent_session_id or '',
|
|
138
|
+
'device_id': self.device_id,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
response.raise_for_status()
|
|
142
|
+
return response.json()
|
|
143
|
+
else:
|
|
144
|
+
async with httpx.AsyncClient() as client:
|
|
145
|
+
response = await client.post(
|
|
146
|
+
f'{self.base_url.rstrip("/")}/api/v1/oauth/device/authorize',
|
|
147
|
+
data={
|
|
148
|
+
'client_id': self.client_id,
|
|
149
|
+
'scope': self.scope,
|
|
150
|
+
'agent_session_id': agent_session_id or '',
|
|
151
|
+
'device_id': self.device_id,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
return response.json()
|
|
156
|
+
|
|
157
|
+
async def poll_for_token(
|
|
158
|
+
self,
|
|
159
|
+
device_code: str,
|
|
160
|
+
interval: float = 3.0,
|
|
161
|
+
timeout: float = 1800.0,
|
|
162
|
+
) -> dict | None:
|
|
163
|
+
"""
|
|
164
|
+
Poll for the access token.
|
|
165
|
+
Returns token info when authorized, None if timeout.
|
|
166
|
+
"""
|
|
167
|
+
start_time = time.time()
|
|
168
|
+
|
|
169
|
+
if self.http_client:
|
|
170
|
+
# Use injected client for all requests
|
|
171
|
+
while time.time() - start_time < timeout:
|
|
172
|
+
try:
|
|
173
|
+
response = await self.http_client.post(
|
|
174
|
+
f'{self.base_url.rstrip("/")}/api/v1/oauth/device/token',
|
|
175
|
+
data={
|
|
176
|
+
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
|
|
177
|
+
'device_code': device_code,
|
|
178
|
+
'client_id': self.client_id,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if response.status_code == 200:
|
|
183
|
+
data = response.json()
|
|
184
|
+
|
|
185
|
+
# Check for pending authorization
|
|
186
|
+
if data.get('error') == 'authorization_pending':
|
|
187
|
+
await asyncio.sleep(interval)
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# Check for slow down
|
|
191
|
+
if data.get('error') == 'slow_down':
|
|
192
|
+
interval = data.get('interval', interval * 2)
|
|
193
|
+
await asyncio.sleep(interval)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Check for other errors
|
|
197
|
+
if 'error' in data:
|
|
198
|
+
print(f'Error: {data.get("error_description", data["error"])}')
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
# Success! We have a token
|
|
202
|
+
if 'access_token' in data:
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
elif response.status_code == 400:
|
|
206
|
+
# Error response
|
|
207
|
+
data = response.json()
|
|
208
|
+
if data.get('error') not in ['authorization_pending', 'slow_down']:
|
|
209
|
+
print(f'Error: {data.get("error_description", "Unknown error")}')
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
else:
|
|
213
|
+
print(f'Unexpected status code: {response.status_code}')
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f'Error polling for token: {e}')
|
|
218
|
+
|
|
219
|
+
await asyncio.sleep(interval)
|
|
220
|
+
else:
|
|
221
|
+
# Create a new client for polling
|
|
222
|
+
async with httpx.AsyncClient() as client:
|
|
223
|
+
while time.time() - start_time < timeout:
|
|
224
|
+
try:
|
|
225
|
+
response = await client.post(
|
|
226
|
+
f'{self.base_url.rstrip("/")}/api/v1/oauth/device/token',
|
|
227
|
+
data={
|
|
228
|
+
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
|
|
229
|
+
'device_code': device_code,
|
|
230
|
+
'client_id': self.client_id,
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if response.status_code == 200:
|
|
235
|
+
data = response.json()
|
|
236
|
+
|
|
237
|
+
# Check for pending authorization
|
|
238
|
+
if data.get('error') == 'authorization_pending':
|
|
239
|
+
await asyncio.sleep(interval)
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Check for slow down
|
|
243
|
+
if data.get('error') == 'slow_down':
|
|
244
|
+
interval = data.get('interval', interval * 2)
|
|
245
|
+
await asyncio.sleep(interval)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Check for other errors
|
|
249
|
+
if 'error' in data:
|
|
250
|
+
print(f'Error: {data.get("error_description", data["error"])}')
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
# Success! We have a token
|
|
254
|
+
if 'access_token' in data:
|
|
255
|
+
return data
|
|
256
|
+
|
|
257
|
+
elif response.status_code == 400:
|
|
258
|
+
# Error response
|
|
259
|
+
data = response.json()
|
|
260
|
+
if data.get('error') not in ['authorization_pending', 'slow_down']:
|
|
261
|
+
print(f'Error: {data.get("error_description", "Unknown error")}')
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
else:
|
|
265
|
+
print(f'Unexpected status code: {response.status_code}')
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
print(f'Error polling for token: {e}')
|
|
270
|
+
|
|
271
|
+
await asyncio.sleep(interval)
|
|
272
|
+
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
async def authenticate(
|
|
276
|
+
self,
|
|
277
|
+
agent_session_id: str | None = None,
|
|
278
|
+
show_instructions: bool = True,
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Run the full authentication flow.
|
|
282
|
+
Returns True if authentication successful.
|
|
283
|
+
"""
|
|
284
|
+
import logging
|
|
285
|
+
|
|
286
|
+
logger = logging.getLogger(__name__)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
# Start device authorization
|
|
290
|
+
device_auth = await self.start_device_authorization(agent_session_id)
|
|
291
|
+
|
|
292
|
+
# Use frontend URL for user-facing links
|
|
293
|
+
frontend_url = CONFIG.BROWSER_USE_CLOUD_UI_URL or self.base_url.replace('//api.', '//cloud.')
|
|
294
|
+
|
|
295
|
+
# Replace backend URL with frontend URL in verification URIs
|
|
296
|
+
verification_uri = device_auth['verification_uri'].replace(self.base_url, frontend_url)
|
|
297
|
+
verification_uri_complete = device_auth['verification_uri_complete'].replace(self.base_url, frontend_url)
|
|
298
|
+
|
|
299
|
+
terminal_width, _terminal_height = shutil.get_terminal_size((80, 20))
|
|
300
|
+
if show_instructions and CONFIG.BROWSER_USE_CLOUD_SYNC:
|
|
301
|
+
logger.info('─' * max(terminal_width - 40, 20))
|
|
302
|
+
logger.info('🌐 View the details of this run in Browser Use Cloud:')
|
|
303
|
+
logger.info(f' 👉 {verification_uri_complete}')
|
|
304
|
+
logger.info('─' * max(terminal_width - 40, 20) + '\n')
|
|
305
|
+
|
|
306
|
+
# Poll for token
|
|
307
|
+
token_data = await self.poll_for_token(
|
|
308
|
+
device_code=device_auth['device_code'],
|
|
309
|
+
interval=device_auth.get('interval', 5),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if token_data and token_data.get('access_token'):
|
|
313
|
+
# Save authentication
|
|
314
|
+
self.auth_config.api_token = token_data['access_token']
|
|
315
|
+
self.auth_config.user_id = token_data.get('user_id', self.temp_user_id)
|
|
316
|
+
self.auth_config.authorized_at = datetime.now()
|
|
317
|
+
self.auth_config.save_to_file()
|
|
318
|
+
|
|
319
|
+
if show_instructions:
|
|
320
|
+
logger.debug('✅ Authentication successful! Cloud sync is now enabled with your browser-use account.')
|
|
321
|
+
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
except httpx.HTTPStatusError as e:
|
|
325
|
+
# HTTP error with response
|
|
326
|
+
if e.response.status_code == 404:
|
|
327
|
+
logger.warning(
|
|
328
|
+
'Cloud sync authentication endpoint not found (404). Check your BROWSER_USE_CLOUD_API_URL setting.'
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
logger.warning(f'Failed to authenticate with cloud service: HTTP {e.response.status_code} - {e.response.text}')
|
|
332
|
+
except httpx.RequestError as e:
|
|
333
|
+
# Connection/network errors
|
|
334
|
+
# logger.warning(f'Failed to connect to cloud service: {type(e).__name__}: {e}')
|
|
335
|
+
pass
|
|
336
|
+
except Exception as e:
|
|
337
|
+
# Other unexpected errors
|
|
338
|
+
logger.warning(f'❌ Unexpected error during cloud sync authentication: {type(e).__name__}: {e}')
|
|
339
|
+
|
|
340
|
+
if show_instructions:
|
|
341
|
+
logger.debug(f'❌ Sync authentication failed or timed out with {CONFIG.BROWSER_USE_CLOUD_API_URL}')
|
|
342
|
+
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
def get_headers(self) -> dict:
|
|
346
|
+
"""Get headers for API requests"""
|
|
347
|
+
if self.api_token:
|
|
348
|
+
return {'Authorization': f'Bearer {self.api_token}'}
|
|
349
|
+
return {}
|
|
350
|
+
|
|
351
|
+
def clear_auth(self) -> None:
|
|
352
|
+
"""Clear stored authentication"""
|
|
353
|
+
self.auth_config = CloudAuthConfig()
|
|
354
|
+
|
|
355
|
+
# Remove the config file entirely instead of saving empty values
|
|
356
|
+
config_path = CONFIG.BROWSER_USE_CONFIG_DIR / 'cloud_auth.json'
|
|
357
|
+
config_path.unlink(missing_ok=True)
|