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,461 @@
|
|
|
1
|
+
"""Local browser watchdog for managing browser subprocess lifecycle."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
from bubus import BaseEvent
|
|
12
|
+
from pydantic import PrivateAttr
|
|
13
|
+
|
|
14
|
+
from browser_use.browser.events import (
|
|
15
|
+
BrowserKillEvent,
|
|
16
|
+
BrowserLaunchEvent,
|
|
17
|
+
BrowserLaunchResult,
|
|
18
|
+
BrowserStopEvent,
|
|
19
|
+
)
|
|
20
|
+
from browser_use.browser.watchdog_base import BaseWatchdog
|
|
21
|
+
from browser_use.observability import observe_debug
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LocalBrowserWatchdog(BaseWatchdog):
|
|
28
|
+
"""Manages local browser subprocess lifecycle."""
|
|
29
|
+
|
|
30
|
+
# Events this watchdog listens to
|
|
31
|
+
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
|
|
32
|
+
BrowserLaunchEvent,
|
|
33
|
+
BrowserKillEvent,
|
|
34
|
+
BrowserStopEvent,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Events this watchdog emits
|
|
38
|
+
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
|
|
39
|
+
|
|
40
|
+
# Private state for subprocess management
|
|
41
|
+
_subprocess: psutil.Process | None = PrivateAttr(default=None)
|
|
42
|
+
_owns_browser_resources: bool = PrivateAttr(default=True)
|
|
43
|
+
_temp_dirs_to_cleanup: list[Path] = PrivateAttr(default_factory=list)
|
|
44
|
+
_original_user_data_dir: str | None = PrivateAttr(default=None)
|
|
45
|
+
|
|
46
|
+
@observe_debug(ignore_input=True, ignore_output=True, name='browser_launch_event')
|
|
47
|
+
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> BrowserLaunchResult:
|
|
48
|
+
"""Launch a local browser process."""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
self.logger.debug('[LocalBrowserWatchdog] Received BrowserLaunchEvent, launching local browser...')
|
|
52
|
+
|
|
53
|
+
# self.logger.debug('[LocalBrowserWatchdog] Calling _launch_browser...')
|
|
54
|
+
process, cdp_url = await self._launch_browser()
|
|
55
|
+
self._subprocess = process
|
|
56
|
+
# self.logger.debug(f'[LocalBrowserWatchdog] _launch_browser returned: process={process}, cdp_url={cdp_url}')
|
|
57
|
+
|
|
58
|
+
return BrowserLaunchResult(cdp_url=cdp_url)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
self.logger.error(f'[LocalBrowserWatchdog] Exception in on_BrowserLaunchEvent: {e}', exc_info=True)
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
async def on_BrowserKillEvent(self, event: BrowserKillEvent) -> None:
|
|
64
|
+
"""Kill the local browser subprocess."""
|
|
65
|
+
self.logger.debug('[LocalBrowserWatchdog] Killing local browser process')
|
|
66
|
+
|
|
67
|
+
if self._subprocess:
|
|
68
|
+
await self._cleanup_process(self._subprocess)
|
|
69
|
+
self._subprocess = None
|
|
70
|
+
|
|
71
|
+
# Clean up temp directories if any were created
|
|
72
|
+
for temp_dir in self._temp_dirs_to_cleanup:
|
|
73
|
+
self._cleanup_temp_dir(temp_dir)
|
|
74
|
+
self._temp_dirs_to_cleanup.clear()
|
|
75
|
+
|
|
76
|
+
# Restore original user_data_dir if it was modified
|
|
77
|
+
if self._original_user_data_dir is not None:
|
|
78
|
+
self.browser_session.browser_profile.user_data_dir = self._original_user_data_dir
|
|
79
|
+
self._original_user_data_dir = None
|
|
80
|
+
|
|
81
|
+
self.logger.debug('[LocalBrowserWatchdog] Browser cleanup completed')
|
|
82
|
+
|
|
83
|
+
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
|
84
|
+
"""Listen for BrowserStopEvent and dispatch BrowserKillEvent without awaiting it."""
|
|
85
|
+
if self.browser_session.is_local and self._subprocess:
|
|
86
|
+
self.logger.debug('[LocalBrowserWatchdog] BrowserStopEvent received, dispatching BrowserKillEvent')
|
|
87
|
+
# Dispatch BrowserKillEvent without awaiting so it gets processed after all BrowserStopEvent handlers
|
|
88
|
+
self.event_bus.dispatch(BrowserKillEvent())
|
|
89
|
+
|
|
90
|
+
@observe_debug(ignore_input=True, ignore_output=True, name='launch_browser_process')
|
|
91
|
+
async def _launch_browser(self, max_retries: int = 3) -> tuple[psutil.Process, str]:
|
|
92
|
+
"""Launch browser process and return (process, cdp_url).
|
|
93
|
+
|
|
94
|
+
Handles launch errors by falling back to temporary directories if needed.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (psutil.Process, cdp_url)
|
|
98
|
+
"""
|
|
99
|
+
# Keep track of original user_data_dir to restore if needed
|
|
100
|
+
profile = self.browser_session.browser_profile
|
|
101
|
+
self._original_user_data_dir = str(profile.user_data_dir) if profile.user_data_dir else None
|
|
102
|
+
self._temp_dirs_to_cleanup = []
|
|
103
|
+
|
|
104
|
+
for attempt in range(max_retries):
|
|
105
|
+
try:
|
|
106
|
+
# Get launch args from profile
|
|
107
|
+
launch_args = profile.get_args()
|
|
108
|
+
|
|
109
|
+
# Add debugging port
|
|
110
|
+
debug_port = self._find_free_port()
|
|
111
|
+
launch_args.extend(
|
|
112
|
+
[
|
|
113
|
+
f'--remote-debugging-port={debug_port}',
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
assert '--user-data-dir' in str(launch_args), (
|
|
117
|
+
'User data dir must be set somewhere in launch args to a non-default path, otherwise Chrome will not let us attach via CDP'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Get browser executable
|
|
121
|
+
# Priority: custom executable > fallback paths > playwright subprocess
|
|
122
|
+
if profile.executable_path:
|
|
123
|
+
browser_path = profile.executable_path
|
|
124
|
+
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Using custom local browser executable_path= {browser_path}')
|
|
125
|
+
else:
|
|
126
|
+
# self.logger.debug('[LocalBrowserWatchdog] 🔍 Looking for local browser binary path...')
|
|
127
|
+
# Try fallback paths first (system browsers preferred)
|
|
128
|
+
browser_path = self._find_installed_browser_path()
|
|
129
|
+
if not browser_path:
|
|
130
|
+
self.logger.error(
|
|
131
|
+
'[LocalBrowserWatchdog] ⚠️ No local browser binary found, installing browser using playwright subprocess...'
|
|
132
|
+
)
|
|
133
|
+
browser_path = await self._install_browser_with_playwright()
|
|
134
|
+
|
|
135
|
+
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Found local browser installed at executable_path= {browser_path}')
|
|
136
|
+
if not browser_path:
|
|
137
|
+
raise RuntimeError('No local Chrome/Chromium install found, and failed to install with playwright')
|
|
138
|
+
|
|
139
|
+
# Launch browser subprocess directly
|
|
140
|
+
self.logger.debug(f'[LocalBrowserWatchdog] 🚀 Launching browser subprocess with {len(launch_args)} args...')
|
|
141
|
+
self.logger.debug(
|
|
142
|
+
f'[LocalBrowserWatchdog] 📂 user_data_dir={profile.user_data_dir}, profile_directory={profile.profile_directory}'
|
|
143
|
+
)
|
|
144
|
+
subprocess = await asyncio.create_subprocess_exec(
|
|
145
|
+
browser_path,
|
|
146
|
+
*launch_args,
|
|
147
|
+
stdout=asyncio.subprocess.PIPE,
|
|
148
|
+
stderr=asyncio.subprocess.PIPE,
|
|
149
|
+
)
|
|
150
|
+
self.logger.debug(
|
|
151
|
+
f'[LocalBrowserWatchdog] 🎭 Browser running with browser_pid= {subprocess.pid} 🔗 listening on CDP port :{debug_port}'
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Convert to psutil.Process
|
|
155
|
+
process = psutil.Process(subprocess.pid)
|
|
156
|
+
|
|
157
|
+
# Wait for CDP to be ready and get the URL
|
|
158
|
+
cdp_url = await self._wait_for_cdp_url(debug_port)
|
|
159
|
+
|
|
160
|
+
# Success! Clean up any temp dirs we created but didn't use
|
|
161
|
+
for tmp_dir in self._temp_dirs_to_cleanup:
|
|
162
|
+
try:
|
|
163
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
return process, cdp_url
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
error_str = str(e).lower()
|
|
171
|
+
|
|
172
|
+
# Check if this is a user_data_dir related error
|
|
173
|
+
if any(err in error_str for err in ['singletonlock', 'user data directory', 'cannot create', 'already in use']):
|
|
174
|
+
self.logger.warning(f'Browser launch failed (attempt {attempt + 1}/{max_retries}): {e}')
|
|
175
|
+
|
|
176
|
+
if attempt < max_retries - 1:
|
|
177
|
+
# Create a temporary directory for next attempt
|
|
178
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix='browseruse-tmp-'))
|
|
179
|
+
self._temp_dirs_to_cleanup.append(tmp_dir)
|
|
180
|
+
|
|
181
|
+
# Update profile to use temp directory
|
|
182
|
+
profile.user_data_dir = str(tmp_dir)
|
|
183
|
+
self.logger.debug(f'Retrying with temporary user_data_dir: {tmp_dir}')
|
|
184
|
+
|
|
185
|
+
# Small delay before retry
|
|
186
|
+
await asyncio.sleep(0.5)
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Not a recoverable error or last attempt failed
|
|
190
|
+
# Restore original user_data_dir before raising
|
|
191
|
+
if self._original_user_data_dir is not None:
|
|
192
|
+
profile.user_data_dir = self._original_user_data_dir
|
|
193
|
+
|
|
194
|
+
# Clean up any temp dirs we created
|
|
195
|
+
for tmp_dir in self._temp_dirs_to_cleanup:
|
|
196
|
+
try:
|
|
197
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
# Should not reach here, but just in case
|
|
204
|
+
if self._original_user_data_dir is not None:
|
|
205
|
+
profile.user_data_dir = self._original_user_data_dir
|
|
206
|
+
raise RuntimeError(f'Failed to launch browser after {max_retries} attempts')
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _find_installed_browser_path() -> str | None:
|
|
210
|
+
"""Try to find browser executable from common fallback locations.
|
|
211
|
+
|
|
212
|
+
Prioritizes:
|
|
213
|
+
1. System Chrome Stable
|
|
214
|
+
1. Playwright chromium
|
|
215
|
+
2. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave)
|
|
216
|
+
3. Playwright headless-shell fallback
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Path to browser executable or None if not found
|
|
220
|
+
"""
|
|
221
|
+
import glob
|
|
222
|
+
import platform
|
|
223
|
+
from pathlib import Path
|
|
224
|
+
|
|
225
|
+
system = platform.system()
|
|
226
|
+
patterns = []
|
|
227
|
+
|
|
228
|
+
# Get playwright browsers path from environment variable if set
|
|
229
|
+
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
|
|
230
|
+
|
|
231
|
+
if system == 'Darwin': # macOS
|
|
232
|
+
if not playwright_path:
|
|
233
|
+
playwright_path = '~/Library/Caches/ms-playwright'
|
|
234
|
+
patterns = [
|
|
235
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
236
|
+
f'{playwright_path}/chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
|
237
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
238
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
239
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
240
|
+
f'{playwright_path}/chromium_headless_shell-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
|
241
|
+
]
|
|
242
|
+
elif system == 'Linux':
|
|
243
|
+
if not playwright_path:
|
|
244
|
+
playwright_path = '~/.cache/ms-playwright'
|
|
245
|
+
patterns = [
|
|
246
|
+
'/usr/bin/google-chrome-stable',
|
|
247
|
+
'/usr/bin/google-chrome',
|
|
248
|
+
'/usr/local/bin/google-chrome',
|
|
249
|
+
f'{playwright_path}/chromium-*/chrome-linux/chrome',
|
|
250
|
+
'/usr/bin/chromium',
|
|
251
|
+
'/usr/bin/chromium-browser',
|
|
252
|
+
'/usr/local/bin/chromium',
|
|
253
|
+
'/snap/bin/chromium',
|
|
254
|
+
'/usr/bin/google-chrome-beta',
|
|
255
|
+
'/usr/bin/google-chrome-dev',
|
|
256
|
+
'/usr/bin/brave-browser',
|
|
257
|
+
f'{playwright_path}/chromium_headless_shell-*/chrome-linux/chrome',
|
|
258
|
+
]
|
|
259
|
+
elif system == 'Windows':
|
|
260
|
+
if not playwright_path:
|
|
261
|
+
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
|
|
262
|
+
patterns = [
|
|
263
|
+
r'C:\Program Files\Google\Chrome\Application\chrome.exe',
|
|
264
|
+
r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
|
|
265
|
+
r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe',
|
|
266
|
+
r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe',
|
|
267
|
+
r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe',
|
|
268
|
+
f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe',
|
|
269
|
+
r'C:\Program Files\Chromium\Application\chrome.exe',
|
|
270
|
+
r'C:\Program Files (x86)\Chromium\Application\chrome.exe',
|
|
271
|
+
r'%LOCALAPPDATA%\Chromium\Application\chrome.exe',
|
|
272
|
+
r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe',
|
|
273
|
+
r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe',
|
|
274
|
+
r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
|
|
275
|
+
r'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
|
|
276
|
+
r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe',
|
|
277
|
+
f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe',
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
for pattern in patterns:
|
|
281
|
+
# Expand user home directory
|
|
282
|
+
expanded_pattern = Path(pattern).expanduser()
|
|
283
|
+
|
|
284
|
+
# Handle Windows environment variables
|
|
285
|
+
if system == 'Windows':
|
|
286
|
+
pattern_str = str(expanded_pattern)
|
|
287
|
+
for env_var in ['%LOCALAPPDATA%', '%PROGRAMFILES%', '%PROGRAMFILES(X86)%']:
|
|
288
|
+
if env_var in pattern_str:
|
|
289
|
+
env_key = env_var.strip('%').replace('(X86)', ' (x86)')
|
|
290
|
+
env_value = os.environ.get(env_key, '')
|
|
291
|
+
if env_value:
|
|
292
|
+
pattern_str = pattern_str.replace(env_var, env_value)
|
|
293
|
+
expanded_pattern = Path(pattern_str)
|
|
294
|
+
|
|
295
|
+
# Convert to string for glob
|
|
296
|
+
pattern_str = str(expanded_pattern)
|
|
297
|
+
|
|
298
|
+
# Check if pattern contains wildcards
|
|
299
|
+
if '*' in pattern_str:
|
|
300
|
+
# Use glob to expand the pattern
|
|
301
|
+
matches = glob.glob(pattern_str)
|
|
302
|
+
if matches:
|
|
303
|
+
# Sort matches and take the last one (alphanumerically highest version)
|
|
304
|
+
matches.sort()
|
|
305
|
+
browser_path = matches[-1]
|
|
306
|
+
if Path(browser_path).exists() and Path(browser_path).is_file():
|
|
307
|
+
return browser_path
|
|
308
|
+
else:
|
|
309
|
+
# Direct path check
|
|
310
|
+
if expanded_pattern.exists() and expanded_pattern.is_file():
|
|
311
|
+
return str(expanded_pattern)
|
|
312
|
+
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
async def _install_browser_with_playwright(self) -> str:
|
|
316
|
+
"""Get browser executable path from playwright in a subprocess to avoid thread issues."""
|
|
317
|
+
import platform
|
|
318
|
+
|
|
319
|
+
# Build command - only use --with-deps on Linux (it fails on Windows/macOS)
|
|
320
|
+
cmd = ['uvx', 'playwright', 'install', 'chrome']
|
|
321
|
+
if platform.system() == 'Linux':
|
|
322
|
+
cmd.append('--with-deps')
|
|
323
|
+
|
|
324
|
+
# Run in subprocess with timeout
|
|
325
|
+
process = await asyncio.create_subprocess_exec(
|
|
326
|
+
*cmd,
|
|
327
|
+
stdout=asyncio.subprocess.PIPE,
|
|
328
|
+
stderr=asyncio.subprocess.PIPE,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
|
|
333
|
+
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Playwright install output: {stdout}')
|
|
334
|
+
browser_path = self._find_installed_browser_path()
|
|
335
|
+
if browser_path:
|
|
336
|
+
return browser_path
|
|
337
|
+
self.logger.error(f'[LocalBrowserWatchdog] ❌ Playwright local browser installation error: \n{stdout}\n{stderr}')
|
|
338
|
+
raise RuntimeError('No local browser path found after: uvx playwright install chrome')
|
|
339
|
+
except TimeoutError:
|
|
340
|
+
# Kill the subprocess if it times out
|
|
341
|
+
process.kill()
|
|
342
|
+
await process.wait()
|
|
343
|
+
raise RuntimeError('Timeout getting browser path from playwright')
|
|
344
|
+
except Exception as e:
|
|
345
|
+
# Make sure subprocess is terminated
|
|
346
|
+
if process.returncode is None:
|
|
347
|
+
process.kill()
|
|
348
|
+
await process.wait()
|
|
349
|
+
raise RuntimeError(f'Error getting browser path: {e}')
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _find_free_port() -> int:
|
|
353
|
+
"""Find a free port for the debugging interface."""
|
|
354
|
+
import socket
|
|
355
|
+
|
|
356
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
357
|
+
s.bind(('127.0.0.1', 0))
|
|
358
|
+
s.listen(1)
|
|
359
|
+
port = s.getsockname()[1]
|
|
360
|
+
return port
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
async def _wait_for_cdp_url(port: int, timeout: float = 30) -> str:
|
|
364
|
+
"""Wait for the browser to start and return the CDP URL."""
|
|
365
|
+
import aiohttp
|
|
366
|
+
|
|
367
|
+
start_time = asyncio.get_event_loop().time()
|
|
368
|
+
|
|
369
|
+
while asyncio.get_event_loop().time() - start_time < timeout:
|
|
370
|
+
try:
|
|
371
|
+
async with aiohttp.ClientSession() as session:
|
|
372
|
+
async with session.get(f'http://localhost:{port}/json/version') as resp:
|
|
373
|
+
if resp.status == 200:
|
|
374
|
+
# Chrome is ready
|
|
375
|
+
return f'http://localhost:{port}/'
|
|
376
|
+
else:
|
|
377
|
+
# Chrome is starting up and returning 502/500 errors
|
|
378
|
+
await asyncio.sleep(0.1)
|
|
379
|
+
except Exception:
|
|
380
|
+
# Connection error - Chrome might not be ready yet
|
|
381
|
+
await asyncio.sleep(0.1)
|
|
382
|
+
|
|
383
|
+
raise TimeoutError(f'Browser did not start within {timeout} seconds')
|
|
384
|
+
|
|
385
|
+
@staticmethod
|
|
386
|
+
async def _cleanup_process(process: psutil.Process) -> None:
|
|
387
|
+
"""Clean up browser process.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
process: psutil.Process to terminate
|
|
391
|
+
"""
|
|
392
|
+
if not process:
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
# Try graceful shutdown first
|
|
397
|
+
process.terminate()
|
|
398
|
+
|
|
399
|
+
# Use async wait instead of blocking wait
|
|
400
|
+
for _ in range(50): # Wait up to 5 seconds (50 * 0.1)
|
|
401
|
+
if not process.is_running():
|
|
402
|
+
return
|
|
403
|
+
await asyncio.sleep(0.1)
|
|
404
|
+
|
|
405
|
+
# If still running after 5 seconds, force kill
|
|
406
|
+
if process.is_running():
|
|
407
|
+
process.kill()
|
|
408
|
+
# Give it a moment to die
|
|
409
|
+
await asyncio.sleep(0.1)
|
|
410
|
+
|
|
411
|
+
except psutil.NoSuchProcess:
|
|
412
|
+
# Process already gone
|
|
413
|
+
pass
|
|
414
|
+
except Exception:
|
|
415
|
+
# Ignore any other errors during cleanup
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
def _cleanup_temp_dir(self, temp_dir: Path | str) -> None:
|
|
419
|
+
"""Clean up temporary directory.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
temp_dir: Path to temporary directory to remove
|
|
423
|
+
"""
|
|
424
|
+
if not temp_dir:
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
temp_path = Path(temp_dir)
|
|
429
|
+
# Only remove if it's actually a temp directory we created
|
|
430
|
+
if 'browseruse-tmp-' in str(temp_path):
|
|
431
|
+
shutil.rmtree(temp_path, ignore_errors=True)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
self.logger.debug(f'Failed to cleanup temp dir {temp_dir}: {e}')
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def browser_pid(self) -> int | None:
|
|
437
|
+
"""Get the browser process ID."""
|
|
438
|
+
if self._subprocess:
|
|
439
|
+
return self._subprocess.pid
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
async def get_browser_pid_via_cdp(browser) -> int | None:
|
|
444
|
+
"""Get the browser process ID via CDP SystemInfo.getProcessInfo.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
browser: Playwright Browser instance
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Process ID or None if failed
|
|
451
|
+
"""
|
|
452
|
+
try:
|
|
453
|
+
cdp_session = await browser.new_browser_cdp_session()
|
|
454
|
+
result = await cdp_session.send('SystemInfo.getProcessInfo')
|
|
455
|
+
process_info = result.get('processInfo', {})
|
|
456
|
+
pid = process_info.get('id')
|
|
457
|
+
await cdp_session.detach()
|
|
458
|
+
return pid
|
|
459
|
+
except Exception:
|
|
460
|
+
# If we can't get PID via CDP, it's not critical
|
|
461
|
+
return None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Permissions watchdog for granting browser permissions on connection."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
4
|
+
|
|
5
|
+
from bubus import BaseEvent
|
|
6
|
+
|
|
7
|
+
from browser_use.browser.events import BrowserConnectedEvent
|
|
8
|
+
from browser_use.browser.watchdog_base import BaseWatchdog
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PermissionsWatchdog(BaseWatchdog):
|
|
15
|
+
"""Grants browser permissions when browser connects."""
|
|
16
|
+
|
|
17
|
+
# Event contracts
|
|
18
|
+
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
|
19
|
+
BrowserConnectedEvent,
|
|
20
|
+
]
|
|
21
|
+
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
|
22
|
+
|
|
23
|
+
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
|
24
|
+
"""Grant permissions when browser connects."""
|
|
25
|
+
permissions = self.browser_session.browser_profile.permissions
|
|
26
|
+
|
|
27
|
+
if not permissions:
|
|
28
|
+
self.logger.debug('No permissions to grant')
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
self.logger.debug(f'🔓 Granting browser permissions: {permissions}')
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
# Grant permissions using CDP Browser.grantPermissions
|
|
35
|
+
# origin=None means grant to all origins
|
|
36
|
+
# Browser domain commands don't use session_id
|
|
37
|
+
await self.browser_session.cdp_client.send.Browser.grantPermissions(
|
|
38
|
+
params={'permissions': permissions} # type: ignore
|
|
39
|
+
)
|
|
40
|
+
self.logger.debug(f'✅ Successfully granted permissions: {permissions}')
|
|
41
|
+
except Exception as e:
|
|
42
|
+
self.logger.error(f'❌ Failed to grant permissions: {str(e)}')
|
|
43
|
+
# Don't raise - permissions are not critical to browser operation
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Watchdog for handling JavaScript dialogs (alert, confirm, prompt) automatically."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from bubus import BaseEvent
|
|
7
|
+
from pydantic import PrivateAttr
|
|
8
|
+
|
|
9
|
+
from browser_use.browser.events import TabCreatedEvent
|
|
10
|
+
from browser_use.browser.watchdog_base import BaseWatchdog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PopupsWatchdog(BaseWatchdog):
|
|
14
|
+
"""Handles JavaScript dialogs (alert, confirm, prompt) by automatically accepting them immediately."""
|
|
15
|
+
|
|
16
|
+
# Events this watchdog listens to and emits
|
|
17
|
+
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [TabCreatedEvent]
|
|
18
|
+
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
|
19
|
+
|
|
20
|
+
# Track which targets have dialog handlers registered
|
|
21
|
+
_dialog_listeners_registered: set[str] = PrivateAttr(default_factory=set)
|
|
22
|
+
|
|
23
|
+
def __init__(self, **kwargs):
|
|
24
|
+
super().__init__(**kwargs)
|
|
25
|
+
self.logger.debug(f'🚀 PopupsWatchdog initialized with browser_session={self.browser_session}, ID={id(self)}')
|
|
26
|
+
|
|
27
|
+
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
|
28
|
+
"""Set up JavaScript dialog handling when a new tab is created."""
|
|
29
|
+
target_id = event.target_id
|
|
30
|
+
self.logger.debug(f'🎯 PopupsWatchdog received TabCreatedEvent for target {target_id}')
|
|
31
|
+
|
|
32
|
+
# Skip if we've already registered for this target
|
|
33
|
+
if target_id in self._dialog_listeners_registered:
|
|
34
|
+
self.logger.debug(f'Already registered dialog handlers for target {target_id}')
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
self.logger.debug(f'📌 Starting dialog handler setup for target {target_id}')
|
|
38
|
+
try:
|
|
39
|
+
# Get all CDP sessions for this target and any child frames
|
|
40
|
+
cdp_session = await self.browser_session.get_or_create_cdp_session(
|
|
41
|
+
target_id, focus=False
|
|
42
|
+
) # don't auto-focus new tabs! sometimes we need to open tabs in background
|
|
43
|
+
|
|
44
|
+
# CRITICAL: Enable Page domain to receive dialog events
|
|
45
|
+
try:
|
|
46
|
+
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
|
|
47
|
+
self.logger.debug(f'✅ Enabled Page domain for session {cdp_session.session_id[-8:]}')
|
|
48
|
+
except Exception as e:
|
|
49
|
+
self.logger.debug(f'Failed to enable Page domain: {e}')
|
|
50
|
+
|
|
51
|
+
# Also register for the root CDP client to catch dialogs from any frame
|
|
52
|
+
if self.browser_session._cdp_client_root:
|
|
53
|
+
self.logger.debug('📌 Also registering handler on root CDP client')
|
|
54
|
+
try:
|
|
55
|
+
# Enable Page domain on root client too
|
|
56
|
+
await self.browser_session._cdp_client_root.send.Page.enable()
|
|
57
|
+
self.logger.debug('✅ Enabled Page domain on root CDP client')
|
|
58
|
+
except Exception as e:
|
|
59
|
+
self.logger.debug(f'Failed to enable Page domain on root: {e}')
|
|
60
|
+
|
|
61
|
+
# Set up async handler for JavaScript dialogs - accept immediately without event dispatch
|
|
62
|
+
async def handle_dialog(event_data, session_id: str | None = None):
|
|
63
|
+
"""Handle JavaScript dialog events - accept immediately."""
|
|
64
|
+
try:
|
|
65
|
+
dialog_type = event_data.get('type', 'alert')
|
|
66
|
+
message = event_data.get('message', '')
|
|
67
|
+
|
|
68
|
+
# Store the popup message in browser session for inclusion in browser state
|
|
69
|
+
if message:
|
|
70
|
+
formatted_message = f'[{dialog_type}] {message}'
|
|
71
|
+
self.browser_session._closed_popup_messages.append(formatted_message)
|
|
72
|
+
self.logger.debug(f'📝 Stored popup message: {formatted_message[:100]}')
|
|
73
|
+
|
|
74
|
+
# Choose action based on dialog type:
|
|
75
|
+
# - alert: accept=true (click OK to dismiss)
|
|
76
|
+
# - confirm: accept=true (click OK to proceed - safer for automation)
|
|
77
|
+
# - prompt: accept=false (click Cancel since we can't provide input)
|
|
78
|
+
# - beforeunload: accept=true (allow navigation)
|
|
79
|
+
should_accept = dialog_type in ('alert', 'confirm', 'beforeunload')
|
|
80
|
+
|
|
81
|
+
action_str = 'accepting (OK)' if should_accept else 'dismissing (Cancel)'
|
|
82
|
+
self.logger.info(f"🔔 JavaScript {dialog_type} dialog: '{message[:100]}' - {action_str}...")
|
|
83
|
+
|
|
84
|
+
dismissed = False
|
|
85
|
+
|
|
86
|
+
# Approach 1: Use the session that detected the dialog (most reliable)
|
|
87
|
+
if self.browser_session._cdp_client_root and session_id:
|
|
88
|
+
try:
|
|
89
|
+
self.logger.debug(f'🔄 Approach 1: Using detecting session {session_id[-8:]}')
|
|
90
|
+
await asyncio.wait_for(
|
|
91
|
+
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
|
|
92
|
+
params={'accept': should_accept},
|
|
93
|
+
session_id=session_id,
|
|
94
|
+
),
|
|
95
|
+
timeout=0.5,
|
|
96
|
+
)
|
|
97
|
+
dismissed = True
|
|
98
|
+
self.logger.info('✅ Dialog handled successfully via detecting session')
|
|
99
|
+
except (TimeoutError, Exception) as e:
|
|
100
|
+
self.logger.debug(f'Approach 1 failed: {type(e).__name__}')
|
|
101
|
+
|
|
102
|
+
# Approach 2: Try with current agent focus session
|
|
103
|
+
if not dismissed and self.browser_session._cdp_client_root and self.browser_session.agent_focus:
|
|
104
|
+
try:
|
|
105
|
+
self.logger.debug(
|
|
106
|
+
f'🔄 Approach 2: Using agent focus session {self.browser_session.agent_focus.session_id[-8:]}'
|
|
107
|
+
)
|
|
108
|
+
await asyncio.wait_for(
|
|
109
|
+
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
|
|
110
|
+
params={'accept': should_accept},
|
|
111
|
+
session_id=self.browser_session.agent_focus.session_id,
|
|
112
|
+
),
|
|
113
|
+
timeout=0.5,
|
|
114
|
+
)
|
|
115
|
+
dismissed = True
|
|
116
|
+
self.logger.info('✅ Dialog handled successfully via agent focus session')
|
|
117
|
+
except (TimeoutError, Exception) as e:
|
|
118
|
+
self.logger.debug(f'Approach 2 failed: {type(e).__name__}')
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
self.logger.error(f'❌ Critical error in dialog handler: {type(e).__name__}: {e}')
|
|
122
|
+
|
|
123
|
+
# Register handler on the specific session
|
|
124
|
+
cdp_session.cdp_client.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
|
|
125
|
+
self.logger.debug(
|
|
126
|
+
f'Successfully registered Page.javascriptDialogOpening handler for session {cdp_session.session_id}'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Also register on root CDP client to catch dialogs from any frame
|
|
130
|
+
if hasattr(self.browser_session._cdp_client_root, 'register'):
|
|
131
|
+
try:
|
|
132
|
+
self.browser_session._cdp_client_root.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
|
|
133
|
+
self.logger.debug('Successfully registered dialog handler on root CDP client for all frames')
|
|
134
|
+
except Exception as root_error:
|
|
135
|
+
self.logger.warning(f'Failed to register on root CDP client: {root_error}')
|
|
136
|
+
|
|
137
|
+
# Mark this target as having dialog handling set up
|
|
138
|
+
self._dialog_listeners_registered.add(target_id)
|
|
139
|
+
|
|
140
|
+
self.logger.debug(f'Set up JavaScript dialog handling for tab {target_id}')
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
self.logger.warning(f'Failed to set up popup handling for tab {target_id}: {e}')
|