browser-workspace 0.4.2 → 0.4.5
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.
- package/README.md +15 -1
- package/SKILL.md +22 -21
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/package.json +1 -1
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/tests/test_contract.py +7 -0
- package/pyproject.toml +6 -2
- package/session_daemon.py +91 -3
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/platform_runner.py +204 -0
- package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
- package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +107 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- package/src/browser_harness/video_render.py +0 -524
|
@@ -1,1588 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os
|
|
3
|
-
import re
|
|
4
|
-
import secrets
|
|
5
|
-
import socket
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
import time
|
|
9
|
-
import urllib.request
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from . import _ipc as ipc
|
|
13
|
-
from . import auth
|
|
14
|
-
from . import paths
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _process_start_time(pid):
|
|
18
|
-
"""Opaque process-start-time fingerprint at PID, or None if unavailable.
|
|
19
|
-
|
|
20
|
-
Two reads returning the same non-None value mean the PID still refers to
|
|
21
|
-
the same process; a different value means the PID was reused. Used by
|
|
22
|
-
restart_daemon() to keep the force-kill recovery path working even when
|
|
23
|
-
the daemon has already torn down its IPC socket (e.g. during a slow
|
|
24
|
-
remote shutdown), without falling back to "trust the pid file" — which
|
|
25
|
-
would re-introduce the PID-reuse hazard.
|
|
26
|
-
|
|
27
|
-
Linux: /proc/<pid>/stat field 22 (starttime in clock ticks since boot).
|
|
28
|
-
macOS: `ps -o lstart= -p <pid>` (an absolute timestamp string).
|
|
29
|
-
Windows: GetProcessTimes via ctypes (FILETIME creation time, 100-ns since 1601).
|
|
30
|
-
Anywhere else: returns None; restart_daemon falls back to its strict
|
|
31
|
-
identify-only check, which is safer than no check at all.
|
|
32
|
-
"""
|
|
33
|
-
if type(pid) is not int or pid <= 0:
|
|
34
|
-
return None
|
|
35
|
-
if sys.platform.startswith("linux"):
|
|
36
|
-
try:
|
|
37
|
-
with open(f"/proc/{pid}/stat", "rb") as f:
|
|
38
|
-
raw = f.read().decode("ascii", errors="replace")
|
|
39
|
-
except (FileNotFoundError, PermissionError, OSError):
|
|
40
|
-
return None
|
|
41
|
-
# Field 2 is `(comm)`; comm can contain spaces and parens, so split off
|
|
42
|
-
# everything after the LAST `)` and index from there.
|
|
43
|
-
try:
|
|
44
|
-
tail = raw[raw.rindex(")") + 2:].split()
|
|
45
|
-
return tail[19] # starttime is field 22 (0-indexed: 21 - skipped 2 = 19)
|
|
46
|
-
except (ValueError, IndexError):
|
|
47
|
-
return None
|
|
48
|
-
if sys.platform == "darwin":
|
|
49
|
-
try:
|
|
50
|
-
out = subprocess.check_output(
|
|
51
|
-
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
52
|
-
stderr=subprocess.DEVNULL, timeout=2,
|
|
53
|
-
)
|
|
54
|
-
except (subprocess.SubprocessError, OSError):
|
|
55
|
-
return None
|
|
56
|
-
s = out.decode("ascii", errors="replace").strip()
|
|
57
|
-
return s or None
|
|
58
|
-
if sys.platform == "win32":
|
|
59
|
-
# Windows users running a remote daemon hit the same slow-shutdown
|
|
60
|
-
# window as POSIX (stop_remote() PATCHes api.browser-use.com after
|
|
61
|
-
# the IPC socket has been torn down). Without a fingerprint here the
|
|
62
|
-
# SIGTERM gate can never pass during that window, leaving an orphan
|
|
63
|
-
# daemon that may continue to hold a billed cloud browser. Use
|
|
64
|
-
# GetProcessTimes via ctypes to read the kernel-reported creation
|
|
65
|
-
# time as a 64-bit FILETIME (100-ns intervals since 1601-01-01).
|
|
66
|
-
try:
|
|
67
|
-
import ctypes
|
|
68
|
-
from ctypes import wintypes
|
|
69
|
-
except ImportError:
|
|
70
|
-
return None
|
|
71
|
-
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
72
|
-
try:
|
|
73
|
-
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
74
|
-
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
75
|
-
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
76
|
-
kernel32.GetProcessTimes.argtypes = [
|
|
77
|
-
wintypes.HANDLE,
|
|
78
|
-
ctypes.POINTER(wintypes.FILETIME),
|
|
79
|
-
ctypes.POINTER(wintypes.FILETIME),
|
|
80
|
-
ctypes.POINTER(wintypes.FILETIME),
|
|
81
|
-
ctypes.POINTER(wintypes.FILETIME),
|
|
82
|
-
]
|
|
83
|
-
kernel32.GetProcessTimes.restype = wintypes.BOOL
|
|
84
|
-
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
85
|
-
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
86
|
-
except (OSError, AttributeError):
|
|
87
|
-
return None
|
|
88
|
-
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
89
|
-
if not h:
|
|
90
|
-
return None
|
|
91
|
-
try:
|
|
92
|
-
creation = wintypes.FILETIME()
|
|
93
|
-
exit_ft = wintypes.FILETIME()
|
|
94
|
-
kernel_ft = wintypes.FILETIME()
|
|
95
|
-
user_ft = wintypes.FILETIME()
|
|
96
|
-
ok = kernel32.GetProcessTimes(
|
|
97
|
-
h, ctypes.byref(creation), ctypes.byref(exit_ft),
|
|
98
|
-
ctypes.byref(kernel_ft), ctypes.byref(user_ft),
|
|
99
|
-
)
|
|
100
|
-
if not ok:
|
|
101
|
-
return None
|
|
102
|
-
return (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
|
103
|
-
finally:
|
|
104
|
-
kernel32.CloseHandle(h)
|
|
105
|
-
return None
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _load_env():
|
|
109
|
-
repo_root = Path(__file__).resolve().parents[2]
|
|
110
|
-
workspace = paths.workspace_dir()
|
|
111
|
-
for p in (repo_root / ".env", workspace / ".env"):
|
|
112
|
-
if not p.exists():
|
|
113
|
-
continue
|
|
114
|
-
_load_env_file(p)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _load_env_file(p):
|
|
118
|
-
for line in p.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
|
119
|
-
line = line.strip()
|
|
120
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
121
|
-
continue
|
|
122
|
-
k, v = line.split("=", 1)
|
|
123
|
-
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
_load_env()
|
|
127
|
-
|
|
128
|
-
NAME = os.environ.get("BU_NAME", "default")
|
|
129
|
-
BU_API = "https://api.browser-use.com/api/v3"
|
|
130
|
-
PYPI_JSON = "https://pypi.org/pypi/browser-harness/json"
|
|
131
|
-
VERSION_CACHE = paths.config_dir() / "version-cache.json"
|
|
132
|
-
VERSION_CACHE_TTL = 24 * 3600
|
|
133
|
-
DOCTOR_TEXT_LIMIT = 140
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _log_tail(name):
|
|
137
|
-
try:
|
|
138
|
-
return ipc.log_path(name or NAME).read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1]
|
|
139
|
-
except (FileNotFoundError, IndexError, OSError):
|
|
140
|
-
return None
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _is_daemon_process(pid):
|
|
144
|
-
"""Best effort: does `pid` look like one of our daemons?
|
|
145
|
-
|
|
146
|
-
Guards against pid reuse — a dead daemon's number can be handed to an
|
|
147
|
-
unrelated process, and treating that as parked would make every caller wait
|
|
148
|
-
instead of spawning. Where the check cannot run (Windows, no ps) we fall
|
|
149
|
-
back to the log-freshness guard rather than blocking startup.
|
|
150
|
-
"""
|
|
151
|
-
command = (
|
|
152
|
-
["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
153
|
-
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"]
|
|
154
|
-
if sys.platform == "win32"
|
|
155
|
-
else ["ps", "-o", "command=", "-p", str(pid)]
|
|
156
|
-
)
|
|
157
|
-
try:
|
|
158
|
-
result = subprocess.run(command, capture_output=True, text=True, timeout=2)
|
|
159
|
-
except Exception:
|
|
160
|
-
return False
|
|
161
|
-
if result.returncode != 0:
|
|
162
|
-
return False
|
|
163
|
-
out = result.stdout
|
|
164
|
-
if not out.strip():
|
|
165
|
-
return False
|
|
166
|
-
return "browser_harness" in out
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _pending_pid_record(path):
|
|
170
|
-
"""Read a parent-published PID plus process-start fingerprint."""
|
|
171
|
-
try:
|
|
172
|
-
raw = path.read_text()
|
|
173
|
-
try:
|
|
174
|
-
record = json.loads(raw)
|
|
175
|
-
pid = int(record["pid"])
|
|
176
|
-
fingerprint = record.get("started")
|
|
177
|
-
except (json.JSONDecodeError, TypeError, KeyError):
|
|
178
|
-
fields = raw.split()
|
|
179
|
-
pid = int(fields[0])
|
|
180
|
-
fingerprint = None
|
|
181
|
-
except (FileNotFoundError, OSError, ValueError, IndexError):
|
|
182
|
-
return None
|
|
183
|
-
if pid <= 0:
|
|
184
|
-
return None
|
|
185
|
-
if fingerprint is not None:
|
|
186
|
-
current = _process_start_time(pid)
|
|
187
|
-
return pid if current is not None and current == fingerprint else None
|
|
188
|
-
return pid if _is_daemon_process(pid) else None
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _fingerprinted_pending_generation(path):
|
|
192
|
-
"""Return (PID, start fingerprint) only while that generation is alive."""
|
|
193
|
-
try:
|
|
194
|
-
record = json.loads(path.read_text())
|
|
195
|
-
pid = record["pid"]
|
|
196
|
-
fingerprint = record.get("started")
|
|
197
|
-
except (FileNotFoundError, OSError, TypeError, KeyError, json.JSONDecodeError):
|
|
198
|
-
return None
|
|
199
|
-
if type(pid) is not int or not 0 < pid < (1 << 31) or fingerprint is None:
|
|
200
|
-
return None
|
|
201
|
-
current = _process_start_time(pid)
|
|
202
|
-
return (pid, fingerprint) if current is not None and current == fingerprint else None
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _fingerprinted_pending_pid(path):
|
|
206
|
-
generation = _fingerprinted_pending_generation(path)
|
|
207
|
-
return generation[0] if generation is not None else None
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _pid_number(path):
|
|
211
|
-
"""Read only the PID field, without treating it as a trusted identity."""
|
|
212
|
-
try:
|
|
213
|
-
raw = path.read_text()
|
|
214
|
-
try:
|
|
215
|
-
return int(json.loads(raw)["pid"])
|
|
216
|
-
except (json.JSONDecodeError, TypeError, KeyError):
|
|
217
|
-
return int(raw.split()[0])
|
|
218
|
-
except (FileNotFoundError, OSError, ValueError, IndexError):
|
|
219
|
-
return None
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def _publish_pid(path, pid):
|
|
223
|
-
fingerprint = _process_start_time(pid)
|
|
224
|
-
value = json.dumps({"pid": pid, "started": fingerprint}, sort_keys=True, separators=(",", ":"))
|
|
225
|
-
tmp = path.with_name(f"{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp")
|
|
226
|
-
tmp.write_text(value)
|
|
227
|
-
os.replace(tmp, path)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def _parked_daemon_pid(name=None):
|
|
231
|
-
"""PID of a daemon whose handshake is still parked on Chrome's Allow popup.
|
|
232
|
-
|
|
233
|
-
A parked daemon has no IPC socket yet, so daemon_alive() and identify() both
|
|
234
|
-
say "nothing there" and every caller used to spawn a sibling, raising a
|
|
235
|
-
second popup and truncating the first daemon's log. The pid file plus a
|
|
236
|
-
fresh `handshake-wait` breadcrumb identify it without needing IPC.
|
|
237
|
-
"""
|
|
238
|
-
if not (_log_tail(name) or "").startswith("handshake-wait"):
|
|
239
|
-
return None
|
|
240
|
-
pid = _pending_pid_record(ipc.pid_path(name or NAME))
|
|
241
|
-
if pid is None:
|
|
242
|
-
return None
|
|
243
|
-
return pid
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _starting_daemon_pid(name=None):
|
|
247
|
-
"""PID of a daemon child in the short gap before it writes handshake-wait."""
|
|
248
|
-
path = ipc.pid_path(name or NAME)
|
|
249
|
-
try:
|
|
250
|
-
pid = _pending_pid_record(path)
|
|
251
|
-
except OSError:
|
|
252
|
-
return None
|
|
253
|
-
return pid
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
class _spawn_lock:
|
|
257
|
-
"""Only one process spawns a daemon at a time.
|
|
258
|
-
|
|
259
|
-
Two cold invocations that both miss the parked check would otherwise open
|
|
260
|
-
two connections and raise two popups, which is the bug this file is fixing.
|
|
261
|
-
Best effort: if the lock cannot be taken we still proceed, because failing
|
|
262
|
-
to start is worse than an extra popup.
|
|
263
|
-
"""
|
|
264
|
-
|
|
265
|
-
def __init__(self, name=None, timeout=30.0):
|
|
266
|
-
self.path = ipc.pid_path(name or NAME).with_suffix(".spawnlock")
|
|
267
|
-
self.timeout = timeout
|
|
268
|
-
self.fd = None
|
|
269
|
-
|
|
270
|
-
def _try_lock(self):
|
|
271
|
-
if sys.platform == "win32":
|
|
272
|
-
import msvcrt
|
|
273
|
-
os.lseek(self.fd, 0, os.SEEK_SET)
|
|
274
|
-
try:
|
|
275
|
-
msvcrt.locking(self.fd, msvcrt.LK_NBLCK, 1)
|
|
276
|
-
return True
|
|
277
|
-
except OSError:
|
|
278
|
-
return False
|
|
279
|
-
import fcntl
|
|
280
|
-
try:
|
|
281
|
-
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
282
|
-
return True
|
|
283
|
-
except OSError:
|
|
284
|
-
return False
|
|
285
|
-
|
|
286
|
-
def __enter__(self):
|
|
287
|
-
deadline = time.time() + self.timeout
|
|
288
|
-
self.fd = os.open(str(self.path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
289
|
-
if os.fstat(self.fd).st_size == 0:
|
|
290
|
-
os.write(self.fd, b"1")
|
|
291
|
-
while True:
|
|
292
|
-
if self._try_lock():
|
|
293
|
-
return self
|
|
294
|
-
if time.time() >= deadline:
|
|
295
|
-
os.close(self.fd)
|
|
296
|
-
self.fd = None
|
|
297
|
-
return self
|
|
298
|
-
time.sleep(0.1)
|
|
299
|
-
|
|
300
|
-
def __exit__(self, *exc):
|
|
301
|
-
if self.fd is not None:
|
|
302
|
-
try:
|
|
303
|
-
if sys.platform == "win32":
|
|
304
|
-
import msvcrt
|
|
305
|
-
os.lseek(self.fd, 0, os.SEEK_SET)
|
|
306
|
-
msvcrt.locking(self.fd, msvcrt.LK_UNLCK, 1)
|
|
307
|
-
else:
|
|
308
|
-
import fcntl
|
|
309
|
-
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
310
|
-
except OSError:
|
|
311
|
-
pass
|
|
312
|
-
try:
|
|
313
|
-
os.close(self.fd)
|
|
314
|
-
except OSError:
|
|
315
|
-
pass
|
|
316
|
-
return False
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def _needs_chrome_remote_debugging_prompt(msg):
|
|
320
|
-
"""True when Chrome needs the inspect-page permission flow."""
|
|
321
|
-
lower = (msg or "").lower()
|
|
322
|
-
return (
|
|
323
|
-
"devtoolsactiveport not found" in lower
|
|
324
|
-
or "enable chrome://inspect" in lower
|
|
325
|
-
or "not live yet" in lower
|
|
326
|
-
or (
|
|
327
|
-
"ws handshake failed" in lower
|
|
328
|
-
and (
|
|
329
|
-
"403" in lower
|
|
330
|
-
or "opening handshake" in lower
|
|
331
|
-
or "timed out" in lower
|
|
332
|
-
or "timeout" in lower
|
|
333
|
-
)
|
|
334
|
-
)
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _needs_chrome_permission_popup(msg):
|
|
339
|
-
"""True when Chrome is reachable but waiting on the per-session Allow popup."""
|
|
340
|
-
lower = (msg or "").lower()
|
|
341
|
-
return "permission-blocked" in lower
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def _chrome_not_running(msg):
|
|
345
|
-
"""True when the daemon found no running supported browser"""
|
|
346
|
-
return "chrome-not-running" in (msg or "").lower()
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _is_local_chrome_mode(env=None):
|
|
350
|
-
"""True when the daemon discovers a local Chrome instead of a remote CDP WS."""
|
|
351
|
-
env = env or {}
|
|
352
|
-
return not (
|
|
353
|
-
env.get("BU_CDP_WS")
|
|
354
|
-
or env.get("BU_CDP_URL")
|
|
355
|
-
or os.environ.get("BU_CDP_WS")
|
|
356
|
-
or os.environ.get("BU_CDP_URL")
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _daemon_wait_windows(wait, local):
|
|
361
|
-
"""Return normal startup and Chrome-approval wait windows in seconds."""
|
|
362
|
-
explicit_wait = wait is not None
|
|
363
|
-
startup_wait = float(wait) if explicit_wait else 60.0
|
|
364
|
-
approval_wait = None if local and not explicit_wait else startup_wait
|
|
365
|
-
return startup_wait, approval_wait
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def daemon_alive(name=None):
|
|
369
|
-
# Ping handshake (not a bare connect) so a stale .port file + port reuse
|
|
370
|
-
# after a daemon crash doesn't make us mistake an unrelated listener for ours.
|
|
371
|
-
return ipc.ping(name or NAME, timeout=1.0)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def daemon_browser_kind(name=None):
|
|
375
|
-
"""'cloud' | 'cdp' | 'local' as self-reported by a live daemon, else None.
|
|
376
|
-
|
|
377
|
-
None covers unreachable daemons and pre-browser_kind daemons still running
|
|
378
|
-
from an older version."""
|
|
379
|
-
c = None
|
|
380
|
-
try:
|
|
381
|
-
c, token = ipc.connect(name or NAME, timeout=1.0)
|
|
382
|
-
response = ipc.request(c, token, {"meta": "ping"})
|
|
383
|
-
kind = response.get("browser_kind") if isinstance(response, dict) else None
|
|
384
|
-
return kind if kind in {"cloud", "cdp", "local"} else None
|
|
385
|
-
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, ValueError):
|
|
386
|
-
return None
|
|
387
|
-
finally:
|
|
388
|
-
if c:
|
|
389
|
-
c.close()
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
def _daemon_endpoint_names():
|
|
393
|
-
# BH_RUNTIME_DIR isolates one daemon per dir → no filename-prefix discovery,
|
|
394
|
-
# just check whether our local endpoint exists. Without BH_RUNTIME_DIR, or
|
|
395
|
-
# with BH_RUNTIME_DIR_SHARED=1, _RUNTIME is shared and we glob `bu-*.<suffix>`
|
|
396
|
-
# to find every daemon in that runtime dir.
|
|
397
|
-
suffix = ".port" if ipc.IS_WINDOWS else ".sock"
|
|
398
|
-
if ipc.BH_RUNTIME_DIR and not ipc.BH_RUNTIME_DIR_SHARED:
|
|
399
|
-
return [NAME] if (ipc._RUNTIME / f"bu{suffix}").exists() else []
|
|
400
|
-
names = []
|
|
401
|
-
for p in sorted(ipc._RUNTIME.glob(f"bu-*{suffix}")):
|
|
402
|
-
raw = p.name[3:-len(suffix)]
|
|
403
|
-
try:
|
|
404
|
-
ipc._check(raw)
|
|
405
|
-
except ValueError:
|
|
406
|
-
continue
|
|
407
|
-
names.append(raw)
|
|
408
|
-
return names
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def _daemon_browser_connection(name):
|
|
412
|
-
c = None
|
|
413
|
-
try:
|
|
414
|
-
c, token = ipc.connect(name, timeout=1.0)
|
|
415
|
-
response = ipc.request(c, token, {"meta": "connection_status"})
|
|
416
|
-
if "error" in response:
|
|
417
|
-
return None
|
|
418
|
-
page = response.get("page")
|
|
419
|
-
if page:
|
|
420
|
-
page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""}
|
|
421
|
-
return {"name": name, "page": page}
|
|
422
|
-
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, KeyError, ValueError, json.JSONDecodeError):
|
|
423
|
-
return None
|
|
424
|
-
finally:
|
|
425
|
-
if c:
|
|
426
|
-
c.close()
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def daemon_browser_ready(name=None):
|
|
430
|
-
"""Whether the selected daemon has a healthy attached browser connection."""
|
|
431
|
-
return _daemon_browser_connection(name or NAME) is not None
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def browser_connections():
|
|
435
|
-
"""Live browser-harness daemons with healthy CDP browser connections and their attached page."""
|
|
436
|
-
out = []
|
|
437
|
-
for name in _daemon_endpoint_names():
|
|
438
|
-
conn = _daemon_browser_connection(name)
|
|
439
|
-
if conn:
|
|
440
|
-
out.append(conn)
|
|
441
|
-
return out
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def active_browser_connections():
|
|
445
|
-
"""Count live browser-harness daemons with a healthy CDP browser connection."""
|
|
446
|
-
return len(browser_connections())
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def _doctor_short_text(value, limit=None):
|
|
450
|
-
limit = limit or DOCTOR_TEXT_LIMIT
|
|
451
|
-
value = str(value)
|
|
452
|
-
return value if len(value) <= limit else value[:limit - 3] + "..."
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def _is_snap_browser(path: str) -> bool:
|
|
456
|
-
"""True when a Chrome binary path lives under /snap/ (Snap confinement on Linux)."""
|
|
457
|
-
return bool(path) and "/snap/" in path.lower()
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def _doctor_snap_probe_path(path: str) -> str:
|
|
461
|
-
raw = str(path)
|
|
462
|
-
try:
|
|
463
|
-
resolved = os.path.realpath(raw)
|
|
464
|
-
except OSError:
|
|
465
|
-
resolved = raw
|
|
466
|
-
return raw if _is_snap_browser(raw) else resolved
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def _doctor_probe_chrome_binary_for_snap():
|
|
470
|
-
"""Return (label, probe_path) for the first Chrome/Chromium binary found, else (None, None).
|
|
471
|
-
|
|
472
|
-
Honors BH_CHROME_PATH and CHROME_PATH before searching PATH for common names.
|
|
473
|
-
"""
|
|
474
|
-
import shutil
|
|
475
|
-
|
|
476
|
-
for key in ("BH_CHROME_PATH", "CHROME_PATH"):
|
|
477
|
-
raw = (os.environ.get(key) or "").strip()
|
|
478
|
-
if not raw:
|
|
479
|
-
continue
|
|
480
|
-
p = Path(raw).expanduser()
|
|
481
|
-
try:
|
|
482
|
-
if p.is_file():
|
|
483
|
-
return (p.name, _doctor_snap_probe_path(str(p)))
|
|
484
|
-
except OSError:
|
|
485
|
-
continue
|
|
486
|
-
for cmd in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium"):
|
|
487
|
-
w = shutil.which(cmd)
|
|
488
|
-
if not w:
|
|
489
|
-
continue
|
|
490
|
-
try:
|
|
491
|
-
return (cmd, _doctor_snap_probe_path(w))
|
|
492
|
-
except OSError:
|
|
493
|
-
continue
|
|
494
|
-
return (None, None)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def _snap_linux_headless_doc_url():
|
|
498
|
-
return "https://github.com/browser-use/browser-harness/blob/main/docs/snap-linux-headless.md"
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def run_doctor_fix_snap():
|
|
502
|
-
"""Print steps to replace Snap Chromium with a native Chrome for CDP. Always exit 0."""
|
|
503
|
-
doc = _snap_linux_headless_doc_url()
|
|
504
|
-
print("browser-harness doctor --fix-snap")
|
|
505
|
-
print()
|
|
506
|
-
print("Snap-packaged Chromium cannot expose DevTools the way browser-harness needs.")
|
|
507
|
-
print(f"Full background: {doc}")
|
|
508
|
-
print()
|
|
509
|
-
print("1. Install Google Chrome from Google's .deb (not the Snap store):")
|
|
510
|
-
print(" wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb")
|
|
511
|
-
print(" sudo apt install ./google-chrome-stable_current_amd64.deb")
|
|
512
|
-
print()
|
|
513
|
-
print("2. Point the harness (and your shell) at the native binary so PATH does not")
|
|
514
|
-
print(" pick the Snap wrapper first. Example for bash (~/.bashrc or session env):")
|
|
515
|
-
print(" export BH_CHROME_PATH=/usr/bin/google-chrome-stable")
|
|
516
|
-
print(" # CHROME_PATH is also honored by doctor's snap probe if you prefer that name.")
|
|
517
|
-
print()
|
|
518
|
-
print("3. Launch Chrome from that path (Way 2) or open Chrome normally (Way 1),")
|
|
519
|
-
print(" enable remote debugging per install.md, then verify:")
|
|
520
|
-
print(" browser-harness --doctor")
|
|
521
|
-
print()
|
|
522
|
-
return 0
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
def ensure_daemon(wait=None, name=None, env=None):
|
|
526
|
-
"""Idempotent. Self-heals stale daemon, closed Chrome (launches it), cold
|
|
527
|
-
Chrome, and missing Allow on chrome://inspect."""
|
|
528
|
-
if daemon_alive(name):
|
|
529
|
-
# Stale daemons accept connects AND reply to meta:* (pure Python) even when the
|
|
530
|
-
# CDP WS to Chrome is dead — probe with a real CDP call and require "result".
|
|
531
|
-
# Must go through ipc.connect so this works on Windows (TCP loopback) too;
|
|
532
|
-
# raw AF_UNIX here would fail on every warm call and churn the daemon.
|
|
533
|
-
for last in (False, True):
|
|
534
|
-
try:
|
|
535
|
-
s, token = ipc.connect(name or NAME, timeout=3.0)
|
|
536
|
-
resp = ipc.request(s, token, {"method": "Target.getTargets", "params": {}})
|
|
537
|
-
if "result" in resp: return
|
|
538
|
-
except Exception:
|
|
539
|
-
pass
|
|
540
|
-
if not last: time.sleep(0.5)
|
|
541
|
-
browser_kind = daemon_browser_kind(name)
|
|
542
|
-
if browser_kind in {"cloud", None}:
|
|
543
|
-
# A stale Cloud daemon still owns a billable browser. Its shutdown
|
|
544
|
-
# handler stops that browser before acknowledging, and stays alive
|
|
545
|
-
# when the Cloud stop fails so a later call can retry cleanup. Treat
|
|
546
|
-
# an unknown kind the same way: the health failure that made the
|
|
547
|
-
# daemon stale may also prevent classification, and replacing an
|
|
548
|
-
# unclassified daemon best-effort could orphan a Cloud browser.
|
|
549
|
-
stop_remote_daemon(name or NAME)
|
|
550
|
-
else:
|
|
551
|
-
restart_daemon(name)
|
|
552
|
-
|
|
553
|
-
import subprocess, sys
|
|
554
|
-
local = _is_local_chrome_mode(env)
|
|
555
|
-
startup_wait, approval_wait = _daemon_wait_windows(wait, local)
|
|
556
|
-
# Remote/CDP daemons retain the normal 60s startup bound. Only a local
|
|
557
|
-
# Chrome handshake displaying the per-connection approval sheet removes a
|
|
558
|
-
# default caller's deadline, so Browser Use cloud startup is unaffected.
|
|
559
|
-
launched_browser = None
|
|
560
|
-
opened_inspect = False
|
|
561
|
-
for _ in range(3):
|
|
562
|
-
e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
|
|
563
|
-
try:
|
|
564
|
-
stderr_sink = open(ipc.log_path(name or NAME), "ab")
|
|
565
|
-
except OSError:
|
|
566
|
-
stderr_sink = subprocess.DEVNULL
|
|
567
|
-
if local:
|
|
568
|
-
with _spawn_lock(name, timeout=startup_wait) as lock:
|
|
569
|
-
if lock.fd is None:
|
|
570
|
-
raise RuntimeError("daemon-starting: another browser-harness daemon is still starting; retry later")
|
|
571
|
-
if daemon_alive(name):
|
|
572
|
-
if stderr_sink is not subprocess.DEVNULL:
|
|
573
|
-
stderr_sink.close()
|
|
574
|
-
return
|
|
575
|
-
pending_pid = _parked_daemon_pid(name) or _starting_daemon_pid(name)
|
|
576
|
-
if pending_pid:
|
|
577
|
-
p = None
|
|
578
|
-
else:
|
|
579
|
-
p = subprocess.Popen(
|
|
580
|
-
[sys.executable, "-m", "browser_harness.daemon"],
|
|
581
|
-
env=e, stdout=subprocess.DEVNULL, stderr=stderr_sink, **ipc.spawn_kwargs(),
|
|
582
|
-
)
|
|
583
|
-
_publish_pid(ipc.pid_path(name or NAME), p.pid)
|
|
584
|
-
pending_pid = p.pid
|
|
585
|
-
else:
|
|
586
|
-
p = subprocess.Popen(
|
|
587
|
-
[sys.executable, "-m", "browser_harness.daemon"],
|
|
588
|
-
env=e, stdout=subprocess.DEVNULL, stderr=stderr_sink, **ipc.spawn_kwargs(),
|
|
589
|
-
)
|
|
590
|
-
if stderr_sink is not subprocess.DEVNULL:
|
|
591
|
-
stderr_sink.close()
|
|
592
|
-
spawned = time.monotonic()
|
|
593
|
-
deadline = spawned + startup_wait
|
|
594
|
-
hinted = not local
|
|
595
|
-
approval_waiting = False
|
|
596
|
-
pending_died = False
|
|
597
|
-
while deadline is None or time.monotonic() < deadline:
|
|
598
|
-
if daemon_alive(name):
|
|
599
|
-
_cleanup_unattached_browser_launch(launched_browser)
|
|
600
|
-
return
|
|
601
|
-
if p is not None and p.poll() is not None:
|
|
602
|
-
pending_died = True
|
|
603
|
-
break
|
|
604
|
-
if p is None and pending_pid and _pending_pid_record(ipc.pid_path(name or NAME)) != pending_pid:
|
|
605
|
-
pending_died = True
|
|
606
|
-
break
|
|
607
|
-
log_tail = _log_tail(name) or ""
|
|
608
|
-
if local and not approval_waiting and log_tail.startswith("handshake-wait"):
|
|
609
|
-
approval_waiting = True
|
|
610
|
-
deadline = None if approval_wait is None else max(deadline, spawned + approval_wait)
|
|
611
|
-
if not hinted and time.monotonic() - spawned > 2 and log_tail.startswith("handshake-wait"):
|
|
612
|
-
daemon_name = name or NAME
|
|
613
|
-
approve_command = (
|
|
614
|
-
"browser-harness mac-approve"
|
|
615
|
-
if daemon_name == "default"
|
|
616
|
-
else f"BU_NAME={daemon_name} browser-harness mac-approve"
|
|
617
|
-
)
|
|
618
|
-
action = (
|
|
619
|
-
f"run `{approve_command}` in another shell or click Allow"
|
|
620
|
-
if sys.platform == "darwin"
|
|
621
|
-
else "click Allow"
|
|
622
|
-
)
|
|
623
|
-
print(
|
|
624
|
-
f'browser-harness: Chrome is asking "Allow remote debugging?" — {action} to continue.',
|
|
625
|
-
file=sys.stderr,
|
|
626
|
-
)
|
|
627
|
-
hinted = True
|
|
628
|
-
time.sleep(0.2)
|
|
629
|
-
msg = _log_tail(name) or ""
|
|
630
|
-
permission_wait = local and (msg.startswith("handshake-wait") or _needs_chrome_permission_popup(msg))
|
|
631
|
-
if local and pending_died:
|
|
632
|
-
# Serialize cleanup with publishers and remove only the generation
|
|
633
|
-
# we observed. Another waiter may already have published a healthy
|
|
634
|
-
# successor while this caller was leaving its wait loop.
|
|
635
|
-
with _spawn_lock(name, timeout=1.0) as cleanup_lock:
|
|
636
|
-
if cleanup_lock.fd is not None and _pid_number(ipc.pid_path(name or NAME)) == pending_pid:
|
|
637
|
-
try:
|
|
638
|
-
ipc.pid_path(name or NAME).unlink()
|
|
639
|
-
except FileNotFoundError:
|
|
640
|
-
pass
|
|
641
|
-
if permission_wait:
|
|
642
|
-
# A denied/expired approval may already have dropped its sheet.
|
|
643
|
-
# Never auto-spawn a replacement here: that would immediately
|
|
644
|
-
# create another Chrome prompt and recreate the retry loop.
|
|
645
|
-
raise RuntimeError(
|
|
646
|
-
"permission-blocked: the pending Chrome connection ended before approval; "
|
|
647
|
-
"browser-harness did not retry or create another connection."
|
|
648
|
-
)
|
|
649
|
-
continue
|
|
650
|
-
if local and msg.startswith("handshake-wait"):
|
|
651
|
-
# Leave it running: this daemon's connection is what holds the popup
|
|
652
|
-
# on screen. Killing it dropped the popup and the retry raised a new
|
|
653
|
-
# one, which is how a single approval turned into an endless prompt.
|
|
654
|
-
raise RuntimeError(
|
|
655
|
-
"permission-blocked: Chrome's Allow popup is still open and the pending daemon was left running. "
|
|
656
|
-
"Approve that exact popup; browser-harness did not retry or create another connection."
|
|
657
|
-
)
|
|
658
|
-
if local and _needs_chrome_permission_popup(msg):
|
|
659
|
-
print(
|
|
660
|
-
'browser-harness: Chrome is asking "Allow remote debugging?". '
|
|
661
|
-
"Approve that exact popup; no replacement connection was started.",
|
|
662
|
-
file=sys.stderr,
|
|
663
|
-
)
|
|
664
|
-
raise RuntimeError(
|
|
665
|
-
"permission-blocked: Chrome did not approve the connection; browser-harness did not retry or create another connection."
|
|
666
|
-
)
|
|
667
|
-
if local and launched_browser is None and _chrome_not_running(msg):
|
|
668
|
-
# Chrome is closed — launch the browser and retry
|
|
669
|
-
restart_daemon(name)
|
|
670
|
-
launched_browser = _launch_browser()
|
|
671
|
-
if launched_browser is None:
|
|
672
|
-
raise RuntimeError(
|
|
673
|
-
"chrome-not-running: no supported browser is running and none could be launched -- ask the user to open Chrome, then retry."
|
|
674
|
-
)
|
|
675
|
-
print("browser-harness: Chrome isn't running — launching it. If Chrome shows an \"Allow remote debugging?\" popup, click Allow.", file=sys.stderr)
|
|
676
|
-
from .daemon import supported_browser_running
|
|
677
|
-
boot_deadline = time.time() + 15
|
|
678
|
-
while time.time() < boot_deadline and not supported_browser_running():
|
|
679
|
-
time.sleep(0.3)
|
|
680
|
-
continue
|
|
681
|
-
if local and not opened_inspect and _needs_chrome_remote_debugging_prompt(msg):
|
|
682
|
-
opened_inspect = True
|
|
683
|
-
from .daemon import remote_debugging_toggle_profiles, remote_debugging_user_enabled
|
|
684
|
-
if remote_debugging_user_enabled():
|
|
685
|
-
# chrome://inspect toggle is already on — connection died
|
|
686
|
-
print(
|
|
687
|
-
'browser-harness: Chrome is asking "Allow remote debugging?". '
|
|
688
|
-
"Approve that exact popup; no replacement connection was started.",
|
|
689
|
-
file=sys.stderr,
|
|
690
|
-
)
|
|
691
|
-
raise RuntimeError(
|
|
692
|
-
"permission-blocked: Chrome did not approve the connection; browser-harness did not retry or create another connection."
|
|
693
|
-
)
|
|
694
|
-
restart_daemon(name)
|
|
695
|
-
_open_chrome_inspect_once()
|
|
696
|
-
if remote_debugging_toggle_profiles():
|
|
697
|
-
# Toggle already ticked from a previous run, but Chrome 144+
|
|
698
|
-
# wants new Allow for this browser run.
|
|
699
|
-
todo = 'click Allow on Chrome\'s "Allow remote debugging?" popup (the checkbox is already ticked; if no popup appears, untick and re-tick it)'
|
|
700
|
-
else:
|
|
701
|
-
todo = 'tick "Allow remote debugging for this browser instance" and click Allow on the popup'
|
|
702
|
-
raise RuntimeError(
|
|
703
|
-
f"remote-debugging-setup: opened chrome://inspect/#remote-debugging in Chrome -- ask the user to {todo}. "
|
|
704
|
-
"Warn them Chrome shows ONE more Allow popup when the harness connects on the next attempt (per-connection approval; it is expected, not a re-ask). "
|
|
705
|
-
"Retry after the user confirms; do not retry before."
|
|
706
|
-
)
|
|
707
|
-
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
|
|
708
|
-
raise RuntimeError(f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
def require_existing_daemon(name=None):
|
|
712
|
-
"""Require a healthy existing daemon without spawning or reconnecting.
|
|
713
|
-
|
|
714
|
-
Trusted orchestrators use this after they provision a scoped CDP transport.
|
|
715
|
-
Failing closed prevents a later CLI call from silently discovering a
|
|
716
|
-
different local Chrome when that orchestrator-owned daemon dies.
|
|
717
|
-
"""
|
|
718
|
-
daemon_name = name or NAME
|
|
719
|
-
if not daemon_alive(daemon_name):
|
|
720
|
-
raise RuntimeError(f"required daemon {daemon_name!r} is not running")
|
|
721
|
-
try:
|
|
722
|
-
s, token = ipc.connect(daemon_name, timeout=3.0)
|
|
723
|
-
try:
|
|
724
|
-
resp = ipc.request(s, token, {"method": "Target.getTargets", "params": {}})
|
|
725
|
-
finally:
|
|
726
|
-
s.close()
|
|
727
|
-
except Exception as exc:
|
|
728
|
-
raise RuntimeError(f"required daemon {daemon_name!r} is unhealthy: {exc}") from exc
|
|
729
|
-
if not isinstance(resp, dict) or "result" not in resp:
|
|
730
|
-
raise RuntimeError(f"required daemon {daemon_name!r} failed its CDP health check")
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
def stop_remote_daemon(name="remote"):
|
|
734
|
-
"""Stop a remote daemon and its backing Browser Use cloud browser.
|
|
735
|
-
|
|
736
|
-
Triggers the daemon's clean shutdown, which PATCHes
|
|
737
|
-
/browsers/{id} {"action":"stop"} so billing ends and any profile
|
|
738
|
-
state in the session is persisted."""
|
|
739
|
-
# restart_daemon is misnamed — it only stops the daemon (sends
|
|
740
|
-
# shutdown, SIGTERMs if needed, unlinks socket+pid). It never
|
|
741
|
-
# restarts anything on its own; a follow-up `browser-harness`
|
|
742
|
-
# call would auto-spawn a fresh one via ensure_daemon(). That
|
|
743
|
-
# "run-it-again-to-restart" workflow is why it was named that way.
|
|
744
|
-
restart_daemon(name, require_clean=True)
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def restart_daemon(name=None, require_clean=False):
|
|
748
|
-
"""Best-effort daemon shutdown + socket/pid cleanup.
|
|
749
|
-
|
|
750
|
-
Name is historical: callers typically follow this with another
|
|
751
|
-
`browser-harness` invocation, which auto-spawns a fresh daemon via
|
|
752
|
-
ensure_daemon(). The function itself only stops.
|
|
753
|
-
|
|
754
|
-
With require_clean=True, an unavailable daemon or any response other than
|
|
755
|
-
{"ok": true} raises before endpoint cleanup or process termination.
|
|
756
|
-
|
|
757
|
-
Ready-daemon identity is verified via ipc.identify() before any process
|
|
758
|
-
signal. A local daemon waiting on Chrome approval has no IPC socket yet;
|
|
759
|
-
`--reload` may stop that exact pending generation only when its atomically
|
|
760
|
-
published process-start fingerprint still matches. A stale or legacy PID
|
|
761
|
-
file is never trusted for signaling.
|
|
762
|
-
"""
|
|
763
|
-
import signal
|
|
764
|
-
|
|
765
|
-
name = name or NAME
|
|
766
|
-
pending_path = ipc.pid_path(name)
|
|
767
|
-
pid_path = str(pending_path)
|
|
768
|
-
pending_generation = None
|
|
769
|
-
pending_unverified = False
|
|
770
|
-
if (_log_tail(name) or "").startswith("handshake-wait"):
|
|
771
|
-
parked_pid = _parked_daemon_pid(name)
|
|
772
|
-
pending_generation = _fingerprinted_pending_generation(pending_path)
|
|
773
|
-
pending_unverified = parked_pid is not None and pending_generation is None
|
|
774
|
-
|
|
775
|
-
# Two pieces of information are tracked separately:
|
|
776
|
-
# - daemon_pid: the daemon's self-reported PID, or None. Only daemons
|
|
777
|
-
# running this version (or newer) include `pid` in the ping response;
|
|
778
|
-
# pre-upgrade daemons return {pong: True} only and yield None here.
|
|
779
|
-
# - daemon_alive: whether ANY daemon answers ping. Keeps the shutdown
|
|
780
|
-
# IPC path working across upgrades — without it, a still-running
|
|
781
|
-
# pre-upgrade daemon would have its socket deleted out from under it
|
|
782
|
-
# while the process stayed alive.
|
|
783
|
-
daemon_pid = ipc.identify(name, timeout=5.0)
|
|
784
|
-
daemon_alive = daemon_pid is not None or ipc.ping(name, timeout=1.0)
|
|
785
|
-
if require_clean and not daemon_alive:
|
|
786
|
-
raise RuntimeError(f"daemon {name!r} is unavailable for required clean shutdown")
|
|
787
|
-
if not daemon_alive and pending_unverified:
|
|
788
|
-
raise RuntimeError(
|
|
789
|
-
f"pending daemon {name!r} is live but has no verifiable process fingerprint; "
|
|
790
|
-
"it was not signaled and its ownership records were preserved"
|
|
791
|
-
)
|
|
792
|
-
if not daemon_alive and pending_generation is not None:
|
|
793
|
-
with _spawn_lock(name, timeout=5.0) as owner_lock:
|
|
794
|
-
if owner_lock.fd is None:
|
|
795
|
-
raise RuntimeError(
|
|
796
|
-
f"pending daemon {name!r} is changing ownership; it was not signaled"
|
|
797
|
-
)
|
|
798
|
-
current_generation = _fingerprinted_pending_generation(pending_path)
|
|
799
|
-
if current_generation != pending_generation:
|
|
800
|
-
raise RuntimeError(
|
|
801
|
-
f"pending daemon {name!r} changed ownership; the successor was not signaled "
|
|
802
|
-
"and its records were preserved"
|
|
803
|
-
)
|
|
804
|
-
if ipc.ping(name, timeout=0.2):
|
|
805
|
-
raise RuntimeError(
|
|
806
|
-
f"pending daemon {name!r} became ready during cancellation; run --reload again"
|
|
807
|
-
)
|
|
808
|
-
try:
|
|
809
|
-
os.kill(pending_generation[0], signal.SIGTERM)
|
|
810
|
-
except (ProcessLookupError, OSError, SystemError, OverflowError):
|
|
811
|
-
pass
|
|
812
|
-
ipc.cleanup_endpoint(name)
|
|
813
|
-
try:
|
|
814
|
-
os.unlink(pid_path)
|
|
815
|
-
except FileNotFoundError:
|
|
816
|
-
pass
|
|
817
|
-
return
|
|
818
|
-
# Snapshot the daemon's process start-time as a secondary identity check.
|
|
819
|
-
# The IPC socket can disappear before the process exits (e.g. the shutdown
|
|
820
|
-
# path tears down the socket and then waits on a slow remote `stop` PATCH),
|
|
821
|
-
# so identify() going None partway through is not proof of process death.
|
|
822
|
-
# Comparing start-time before SIGTERM lets us recover the original
|
|
823
|
-
# force-kill behavior for slow shutdowns without re-opening the
|
|
824
|
-
# PID-reuse hole — a reused PID would have a different start-time.
|
|
825
|
-
daemon_start = _process_start_time(daemon_pid)
|
|
826
|
-
|
|
827
|
-
if daemon_alive:
|
|
828
|
-
c = None
|
|
829
|
-
try:
|
|
830
|
-
c, token = ipc.connect(name, timeout=50.0 if require_clean else 5.0)
|
|
831
|
-
response = ipc.request(c, token, {"meta": "shutdown"})
|
|
832
|
-
if require_clean and (
|
|
833
|
-
not isinstance(response, dict)
|
|
834
|
-
or response.get("ok") is not True
|
|
835
|
-
or bool(response.get("error"))
|
|
836
|
-
):
|
|
837
|
-
error = response.get("error") if isinstance(response, dict) else None
|
|
838
|
-
raise RuntimeError(error or f"daemon {name!r} did not confirm clean shutdown")
|
|
839
|
-
except Exception as exc:
|
|
840
|
-
if require_clean:
|
|
841
|
-
if isinstance(exc, RuntimeError):
|
|
842
|
-
raise
|
|
843
|
-
raise RuntimeError(
|
|
844
|
-
f"daemon {name!r} did not confirm clean shutdown: {exc}"
|
|
845
|
-
) from exc
|
|
846
|
-
finally:
|
|
847
|
-
if c is not None:
|
|
848
|
-
close = getattr(c, "close", None)
|
|
849
|
-
if close:
|
|
850
|
-
close()
|
|
851
|
-
|
|
852
|
-
if daemon_pid is not None:
|
|
853
|
-
for _ in range(75):
|
|
854
|
-
try:
|
|
855
|
-
os.kill(daemon_pid, 0)
|
|
856
|
-
time.sleep(0.2)
|
|
857
|
-
except (ProcessLookupError, OSError, SystemError, OverflowError):
|
|
858
|
-
break
|
|
859
|
-
else:
|
|
860
|
-
# Re-verify identity before escalating to SIGTERM. Two acceptable
|
|
861
|
-
# signals, in priority order:
|
|
862
|
-
# 1. ipc.identify() still returns the same PID — daemon's IPC is
|
|
863
|
-
# live, daemon is wedged. Safe to kill.
|
|
864
|
-
# 2. start-time fingerprint of the original PID is unchanged —
|
|
865
|
-
# same process, just slow to exit (e.g. stuck in remote stop).
|
|
866
|
-
# The IPC may already be gone; that's expected.
|
|
867
|
-
# If neither holds, the PID may have been reused; skip SIGTERM.
|
|
868
|
-
verified_pid = ipc.identify(name, timeout=1.0)
|
|
869
|
-
same_process = verified_pid == daemon_pid or (
|
|
870
|
-
daemon_start is not None
|
|
871
|
-
and _process_start_time(daemon_pid) == daemon_start
|
|
872
|
-
)
|
|
873
|
-
if same_process:
|
|
874
|
-
try:
|
|
875
|
-
os.kill(daemon_pid, signal.SIGTERM)
|
|
876
|
-
except (ProcessLookupError, OSError, SystemError, OverflowError):
|
|
877
|
-
pass
|
|
878
|
-
|
|
879
|
-
ipc.cleanup_endpoint(name)
|
|
880
|
-
try:
|
|
881
|
-
os.unlink(pid_path)
|
|
882
|
-
except FileNotFoundError:
|
|
883
|
-
pass
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
def _browser_use(path, method, body=None):
|
|
887
|
-
key = auth.get_browser_use_api_key()
|
|
888
|
-
req = urllib.request.Request(
|
|
889
|
-
f"{BU_API}{path}",
|
|
890
|
-
method=method,
|
|
891
|
-
data=(json.dumps(body).encode() if body is not None else None),
|
|
892
|
-
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
|
|
893
|
-
)
|
|
894
|
-
return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
def _stop_cloud_browser(browser_id, strict=False):
|
|
898
|
-
if not browser_id:
|
|
899
|
-
return True
|
|
900
|
-
last_error = None
|
|
901
|
-
for attempt in range(3):
|
|
902
|
-
try:
|
|
903
|
-
_browser_use(f"/browsers/{browser_id}", "PATCH", {"action": "stop"})
|
|
904
|
-
return True
|
|
905
|
-
except BaseException as exc:
|
|
906
|
-
last_error = exc
|
|
907
|
-
if attempt < 2:
|
|
908
|
-
time.sleep(0.5 * (attempt + 1))
|
|
909
|
-
if strict:
|
|
910
|
-
raise RuntimeError(f"failed to stop remote browser {browser_id}: {last_error}")
|
|
911
|
-
return False
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
def _cdp_ws_from_url(cdp_url):
|
|
915
|
-
return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
def _has_local_gui():
|
|
919
|
-
"""True when this machine plausibly has a browser we can open. False on headless servers."""
|
|
920
|
-
import platform
|
|
921
|
-
system = platform.system()
|
|
922
|
-
if system in ("Darwin", "Windows"):
|
|
923
|
-
return True
|
|
924
|
-
if system == "Linux":
|
|
925
|
-
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
926
|
-
return False
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
def _show_live_url(url):
|
|
930
|
-
"""Print liveUrl and auto-open it locally if there's a GUI."""
|
|
931
|
-
import sys, webbrowser
|
|
932
|
-
if not url: return
|
|
933
|
-
print(url)
|
|
934
|
-
if not _has_local_gui():
|
|
935
|
-
print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
|
|
936
|
-
return
|
|
937
|
-
try:
|
|
938
|
-
webbrowser.open(url, new=2)
|
|
939
|
-
print("(opened liveUrl in your default browser)", file=sys.stderr)
|
|
940
|
-
except Exception as e:
|
|
941
|
-
print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
def _should_show_remote_live_view():
|
|
945
|
-
"""Whether Cloud provisioning should print and open its interactive live view."""
|
|
946
|
-
raw = os.environ.get("BH_OPEN_LIVE_URL")
|
|
947
|
-
if raw is None:
|
|
948
|
-
return True
|
|
949
|
-
value = raw.strip().lower()
|
|
950
|
-
if value in {"0", "false", "no", "off"}:
|
|
951
|
-
return False
|
|
952
|
-
if value in {"1", "true", "yes", "on"}:
|
|
953
|
-
return True
|
|
954
|
-
raise ValueError("BH_OPEN_LIVE_URL must be one of: 1, true, yes, on, 0, false, no, off")
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
def list_cloud_profiles():
|
|
958
|
-
"""List cloud profiles under the current API key.
|
|
959
|
-
|
|
960
|
-
Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
|
|
961
|
-
is the array of domain strings the cloud profile has cookies for — use
|
|
962
|
-
`len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
|
|
963
|
-
detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
|
|
964
|
-
|
|
965
|
-
Paginates through all pages — the API caps `pageSize` at 100."""
|
|
966
|
-
out, page = [], 1
|
|
967
|
-
while True:
|
|
968
|
-
listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
|
|
969
|
-
items = listing.get("items") if isinstance(listing, dict) else listing
|
|
970
|
-
if not items:
|
|
971
|
-
break
|
|
972
|
-
for p in items:
|
|
973
|
-
detail = _browser_use(f"/profiles/{p['id']}", "GET")
|
|
974
|
-
out.append({
|
|
975
|
-
"id": detail["id"],
|
|
976
|
-
"name": detail.get("name"),
|
|
977
|
-
"userId": detail.get("userId"),
|
|
978
|
-
"cookieDomains": detail.get("cookieDomains") or [],
|
|
979
|
-
"lastUsedAt": detail.get("lastUsedAt"),
|
|
980
|
-
})
|
|
981
|
-
if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
|
|
982
|
-
break
|
|
983
|
-
page += 1
|
|
984
|
-
return out
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
def _resolve_profile_name(profile_name):
|
|
988
|
-
"""Find a single cloud profile by exact name; raise if 0 or >1 match."""
|
|
989
|
-
matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
|
|
990
|
-
if not matches:
|
|
991
|
-
raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
|
|
992
|
-
if len(matches) > 1:
|
|
993
|
-
raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
|
|
994
|
-
return matches[0]["id"]
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
|
|
998
|
-
"""Provision a Browser Use cloud browser and start a daemon attached to it.
|
|
999
|
-
|
|
1000
|
-
kwargs forwarded to `POST /browsers` (camelCase):
|
|
1001
|
-
profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
|
|
1002
|
-
profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
|
|
1003
|
-
proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
|
|
1004
|
-
timeout — minutes, 1..240.
|
|
1005
|
-
customProxy — {host, port, username, password, ignoreCertErrors}.
|
|
1006
|
-
browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
|
|
1007
|
-
|
|
1008
|
-
Returns the full browser dict including `liveUrl`. By default, prints that
|
|
1009
|
-
URL and opens it locally when a GUI is detected. Set BH_OPEN_LIVE_URL to
|
|
1010
|
-
0, false, no, or off (case-insensitive) to suppress only those display side
|
|
1011
|
-
effects; the returned URL remains present."""
|
|
1012
|
-
show_live_view = _should_show_remote_live_view()
|
|
1013
|
-
if daemon_alive(name):
|
|
1014
|
-
raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
|
|
1015
|
-
if profileName:
|
|
1016
|
-
if "profileId" in create_kwargs:
|
|
1017
|
-
raise RuntimeError("pass profileName OR profileId, not both")
|
|
1018
|
-
create_kwargs["profileId"] = _resolve_profile_name(profileName)
|
|
1019
|
-
browser = _browser_use("/browsers", "POST", create_kwargs)
|
|
1020
|
-
try:
|
|
1021
|
-
ensure_daemon(
|
|
1022
|
-
name=name,
|
|
1023
|
-
env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
|
|
1024
|
-
)
|
|
1025
|
-
except BaseException as start_error:
|
|
1026
|
-
try:
|
|
1027
|
-
_stop_cloud_browser(browser.get("id"), strict=True)
|
|
1028
|
-
except BaseException as cleanup_error:
|
|
1029
|
-
raise BaseExceptionGroup(
|
|
1030
|
-
"remote daemon startup and cloud browser cleanup both failed",
|
|
1031
|
-
[start_error, cleanup_error],
|
|
1032
|
-
)
|
|
1033
|
-
raise
|
|
1034
|
-
if show_live_view:
|
|
1035
|
-
_show_live_url(browser.get("liveUrl"))
|
|
1036
|
-
return browser
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
def list_local_profiles():
|
|
1040
|
-
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`."""
|
|
1041
|
-
import json, shutil, subprocess
|
|
1042
|
-
if not shutil.which("profile-use"):
|
|
1043
|
-
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
1044
|
-
return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True, encoding="utf-8", errors="replace"))
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
|
|
1048
|
-
include_domains=None, exclude_domains=None):
|
|
1049
|
-
"""Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
|
|
1050
|
-
|
|
1051
|
-
Shells out to `profile-use sync` (v1.0.5+). Requires BROWSER_USE_API_KEY.
|
|
1052
|
-
profile-use copies the profile dir to a temp and syncs from the copy, so Chrome
|
|
1053
|
-
can stay open.
|
|
1054
|
-
|
|
1055
|
-
Args:
|
|
1056
|
-
profile_name: local Chrome profile name (as shown by `list_local_profiles`).
|
|
1057
|
-
browser: disambiguate when multiple browsers have profiles of the
|
|
1058
|
-
same name (e.g. "Google Chrome"). Default: any match.
|
|
1059
|
-
cloud_profile_id: push cookies into this existing cloud profile instead of
|
|
1060
|
-
creating a new one. Idempotent — call again to refresh
|
|
1061
|
-
the same profile. Default: create new.
|
|
1062
|
-
include_domains: only sync cookies for these domains (and subdomains).
|
|
1063
|
-
Leading dot is optional. Example: ["google.com", "stripe.com"].
|
|
1064
|
-
exclude_domains: drop cookies for these domains (and subdomains). Applied
|
|
1065
|
-
before `include_domains` so exclude wins on overlap."""
|
|
1066
|
-
import shutil, subprocess, sys
|
|
1067
|
-
if not shutil.which("profile-use"):
|
|
1068
|
-
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
1069
|
-
key = auth.get_browser_use_api_key()
|
|
1070
|
-
cmd = ["profile-use", "sync", "--profile", profile_name]
|
|
1071
|
-
if browser:
|
|
1072
|
-
cmd += ["--browser", browser]
|
|
1073
|
-
if cloud_profile_id:
|
|
1074
|
-
cmd += ["--cloud-profile-id", cloud_profile_id]
|
|
1075
|
-
for d in include_domains or []:
|
|
1076
|
-
cmd += ["--domain", d]
|
|
1077
|
-
for d in exclude_domains or []:
|
|
1078
|
-
cmd += ["--exclude-domain", d]
|
|
1079
|
-
r = subprocess.run(cmd, text=True, encoding="utf-8", errors="replace", capture_output=True, env={**os.environ, "BROWSER_USE_API_KEY": key})
|
|
1080
|
-
sys.stdout.write(r.stdout)
|
|
1081
|
-
sys.stderr.write(r.stderr)
|
|
1082
|
-
if r.returncode != 0:
|
|
1083
|
-
raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
|
|
1084
|
-
# With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
|
|
1085
|
-
# instead of "Profile created: <uuid>", so we already know the UUID.
|
|
1086
|
-
if cloud_profile_id:
|
|
1087
|
-
return cloud_profile_id
|
|
1088
|
-
m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
|
|
1089
|
-
if not m:
|
|
1090
|
-
raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
|
|
1091
|
-
return m.group(1)
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
def _version():
|
|
1095
|
-
"""Installed version of the browser-harness package. Empty string if unknown."""
|
|
1096
|
-
try:
|
|
1097
|
-
from importlib.metadata import PackageNotFoundError, version
|
|
1098
|
-
try:
|
|
1099
|
-
return version("browser-harness")
|
|
1100
|
-
except PackageNotFoundError:
|
|
1101
|
-
return ""
|
|
1102
|
-
except Exception:
|
|
1103
|
-
return ""
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
def _repo_dir():
|
|
1107
|
-
"""Return the repo root if this install is an editable git clone, else None.
|
|
1108
|
-
|
|
1109
|
-
Only the directories that could actually hold this package as source count:
|
|
1110
|
-
the package's own parent (flat layout) and its grandparent (src layout).
|
|
1111
|
-
Walking all the way up would claim any enclosing repository — a wheel
|
|
1112
|
-
installed into a venv inside the user's project, or a tool install under a
|
|
1113
|
-
dotfiles-managed $HOME — and run_update() would then `git pull` that repo
|
|
1114
|
-
instead of upgrading browser-harness.
|
|
1115
|
-
"""
|
|
1116
|
-
package = Path(__file__).resolve().parent
|
|
1117
|
-
for candidate in (package.parent, package.parent.parent):
|
|
1118
|
-
if (candidate / ".git").is_dir():
|
|
1119
|
-
return candidate
|
|
1120
|
-
return None
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
def _install_mode():
|
|
1124
|
-
""""git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
|
|
1125
|
-
if _repo_dir():
|
|
1126
|
-
return "git"
|
|
1127
|
-
return "pypi" if _version() else "unknown"
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
def _cache_read():
|
|
1131
|
-
try:
|
|
1132
|
-
return json.loads(VERSION_CACHE.read_text(encoding="utf-8"))
|
|
1133
|
-
except (OSError, ValueError):
|
|
1134
|
-
return {}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
def _cache_write(data):
|
|
1138
|
-
try:
|
|
1139
|
-
VERSION_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
1140
|
-
VERSION_CACHE.write_text(json.dumps(data))
|
|
1141
|
-
try:
|
|
1142
|
-
os.chmod(VERSION_CACHE, 0o600)
|
|
1143
|
-
except OSError:
|
|
1144
|
-
pass
|
|
1145
|
-
except OSError:
|
|
1146
|
-
pass
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
def _latest_release_tag(force=False):
|
|
1150
|
-
"""Return latest PyPI version, or None. Cached for 24h to avoid hammering PyPI."""
|
|
1151
|
-
cache = _cache_read()
|
|
1152
|
-
now = time.time()
|
|
1153
|
-
if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
|
|
1154
|
-
return cache["tag"]
|
|
1155
|
-
try:
|
|
1156
|
-
tag = json.loads(urllib.request.urlopen(PYPI_JSON, timeout=5).read()).get("info", {}).get("version") or ""
|
|
1157
|
-
except Exception:
|
|
1158
|
-
return cache.get("tag") # fall back to last known
|
|
1159
|
-
tag = tag.lstrip("v")
|
|
1160
|
-
_cache_write({**cache, "tag": tag, "fetched_at": now})
|
|
1161
|
-
return tag or None
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
def _version_tuple(v):
|
|
1165
|
-
"""Best-effort PEP 440-ish parse: alpha < beta < rc < final."""
|
|
1166
|
-
m = re.match(r"^\s*v?(\d+(?:\.\d+)*)(?:(a|b|rc)(\d+))?", v or "", re.I)
|
|
1167
|
-
if not m:
|
|
1168
|
-
return (0, 0, 0, 3, 0)
|
|
1169
|
-
nums = [int(p) for p in m.group(1).split(".")[:3]]
|
|
1170
|
-
nums.extend([0] * (3 - len(nums)))
|
|
1171
|
-
pre = (m.group(2) or "").lower()
|
|
1172
|
-
pre_rank = {"a": 0, "b": 1, "rc": 2}.get(pre, 3)
|
|
1173
|
-
pre_num = int(m.group(3) or 0)
|
|
1174
|
-
return (*nums, pre_rank, pre_num)
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
def check_for_update():
|
|
1178
|
-
"""(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
|
|
1179
|
-
cur = _version()
|
|
1180
|
-
latest = _latest_release_tag()
|
|
1181
|
-
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
1182
|
-
return cur, latest, newer
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
def print_update_banner(out=None):
|
|
1186
|
-
"""Print the update banner to stderr once per day. Silent when up-to-date or offline."""
|
|
1187
|
-
import sys
|
|
1188
|
-
if os.environ.get("BH_UPDATE_CHECK", "").strip().lower() in {"0", "false", "no", "off"}:
|
|
1189
|
-
return
|
|
1190
|
-
out = out or sys.stderr
|
|
1191
|
-
cache = _cache_read()
|
|
1192
|
-
today = time.strftime("%Y-%m-%d")
|
|
1193
|
-
if cache.get("banner_shown_on") == today:
|
|
1194
|
-
return
|
|
1195
|
-
cur, latest, newer = check_for_update()
|
|
1196
|
-
if not newer:
|
|
1197
|
-
return
|
|
1198
|
-
print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
|
|
1199
|
-
print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
|
|
1200
|
-
_cache_write({**cache, "banner_shown_on": today})
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
def _chrome_running():
|
|
1204
|
-
"""Cross-platform best-effort check for a running Chromium-based browser."""
|
|
1205
|
-
import platform, subprocess
|
|
1206
|
-
system = platform.system()
|
|
1207
|
-
try:
|
|
1208
|
-
if system == "Windows":
|
|
1209
|
-
out = subprocess.check_output(["tasklist"], text=True, errors="replace", timeout=5)
|
|
1210
|
-
names = ("chrome.exe", "msedge.exe", "helium.exe")
|
|
1211
|
-
else:
|
|
1212
|
-
out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, errors="replace", timeout=5)
|
|
1213
|
-
names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge", "helium")
|
|
1214
|
-
return any(n.lower() in out.lower() for n in names)
|
|
1215
|
-
except Exception:
|
|
1216
|
-
return False
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
_BROWSER_LAUNCH = (
|
|
1220
|
-
# (profile-dir fragment, macOS app name, POSIX commands, Windows `start` target)
|
|
1221
|
-
("chrome canary", "Google Chrome Canary", ("google-chrome-canary",), "chrome"),
|
|
1222
|
-
("chromium", "Chromium", ("chromium", "chromium-browser"), "chromium"),
|
|
1223
|
-
("chrome", "Google Chrome", ("google-chrome-stable", "google-chrome"), "chrome"),
|
|
1224
|
-
("edge", "Microsoft Edge", ("microsoft-edge", "microsoft-edge-stable"), "msedge"),
|
|
1225
|
-
("brave-origin", "Brave Origin", (), None),
|
|
1226
|
-
("brave", "Brave Browser", ("brave-browser", "brave"), "brave"),
|
|
1227
|
-
("arc", "Arc", (), None),
|
|
1228
|
-
("dia", "Dia", (), None),
|
|
1229
|
-
("comet", "Comet", (), None),
|
|
1230
|
-
)
|
|
1231
|
-
_DEFAULT_LAUNCH = (
|
|
1232
|
-
"Google Chrome",
|
|
1233
|
-
("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "microsoft-edge"),
|
|
1234
|
-
"chrome",
|
|
1235
|
-
)
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
def _browser_launch_spec(base):
|
|
1239
|
-
"""(mac app, posix commands, windows target) for the browser w profile dir"""
|
|
1240
|
-
tail = "/".join(p.lower() for p in Path(base).parts[-2:])
|
|
1241
|
-
for frag, mac_app, posix_cmds, win_target in _BROWSER_LAUNCH:
|
|
1242
|
-
if frag in tail:
|
|
1243
|
-
return (mac_app, posix_cmds, win_target)
|
|
1244
|
-
return _DEFAULT_LAUNCH
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
def _browser_binary_matches_profile(binary, base):
|
|
1248
|
-
"""True when an explicit browser binary belongs to ``base``."""
|
|
1249
|
-
name = Path(binary).name.lower().removesuffix(".exe")
|
|
1250
|
-
mac_app, posix_cmds, win_target = _browser_launch_spec(base)
|
|
1251
|
-
candidates = (mac_app, *posix_cmds, win_target)
|
|
1252
|
-
|
|
1253
|
-
def normalize(value):
|
|
1254
|
-
return "".join(char for char in (value or "").lower() if char.isalnum())
|
|
1255
|
-
|
|
1256
|
-
normalized_name = normalize(name)
|
|
1257
|
-
return any(normalized_name == normalize(candidate) for candidate in candidates)
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
def _profile_directory_args(base):
|
|
1261
|
-
"""Relaunch skips Chrome's profile picker"""
|
|
1262
|
-
if not base:
|
|
1263
|
-
return []
|
|
1264
|
-
try:
|
|
1265
|
-
state = json.loads((Path(base) / "Local State").read_text(encoding="utf-8", errors="replace"))
|
|
1266
|
-
last = ((state.get("profile") or {}).get("last_used")) or "Default"
|
|
1267
|
-
except (OSError, ValueError, AttributeError):
|
|
1268
|
-
last = "Default"
|
|
1269
|
-
if not isinstance(last, str) or not (Path(base) / last).is_dir():
|
|
1270
|
-
return []
|
|
1271
|
-
return [f"--profile-directory={last}"]
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
def _launch_browser():
|
|
1275
|
-
"""Prefers the browser whose profile already has perm box checked.
|
|
1276
|
-
|
|
1277
|
-
Returns ``(process, profile)`` on success. ``process`` is available only
|
|
1278
|
-
when we launched the browser directly; ``profile`` is the user-data dir we
|
|
1279
|
-
expect that browser to use. The caller uses both to clean up a direct
|
|
1280
|
-
launch that never becomes reachable over CDP.
|
|
1281
|
-
"""
|
|
1282
|
-
import platform, shutil, subprocess
|
|
1283
|
-
from .daemon import PROFILES, remote_debugging_toggle_profiles
|
|
1284
|
-
|
|
1285
|
-
enabled = remote_debugging_toggle_profiles()
|
|
1286
|
-
known_profiles = enabled + [
|
|
1287
|
-
base for base in PROFILES if base not in enabled and (base / "Local State").exists()
|
|
1288
|
-
]
|
|
1289
|
-
system = platform.system()
|
|
1290
|
-
for key in ("BH_CHROME_PATH", "CHROME_PATH"):
|
|
1291
|
-
raw = (os.environ.get(key) or "").strip()
|
|
1292
|
-
if raw and Path(raw).expanduser().is_file():
|
|
1293
|
-
try:
|
|
1294
|
-
binary = Path(raw).expanduser()
|
|
1295
|
-
process = subprocess.Popen(
|
|
1296
|
-
[str(binary)],
|
|
1297
|
-
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(),
|
|
1298
|
-
)
|
|
1299
|
-
profile = next(
|
|
1300
|
-
(base for base in known_profiles if _browser_binary_matches_profile(binary, base)),
|
|
1301
|
-
None,
|
|
1302
|
-
) if system not in ("Darwin", "Windows") else None
|
|
1303
|
-
return process, profile
|
|
1304
|
-
except (OSError, subprocess.SubprocessError):
|
|
1305
|
-
# A path that exists but can't execute (permissions, wrong arch)
|
|
1306
|
-
# must fall through to normal discovery, not abort
|
|
1307
|
-
continue
|
|
1308
|
-
|
|
1309
|
-
base = enabled[0] if enabled else next((b for b in PROFILES if (b / "Local State").exists()), None)
|
|
1310
|
-
mac_app, posix_cmds, win_target = _browser_launch_spec(base) if base else _DEFAULT_LAUNCH
|
|
1311
|
-
profile_args = _profile_directory_args(base)
|
|
1312
|
-
try:
|
|
1313
|
-
if system == "Darwin":
|
|
1314
|
-
cmd = ["open", "-a", mac_app] + (["--args"] + profile_args if profile_args else [])
|
|
1315
|
-
r = subprocess.run(cmd, timeout=10, check=False, capture_output=True)
|
|
1316
|
-
if r.returncode != 0 and mac_app != "Google Chrome":
|
|
1317
|
-
# Different app → its profile dir may not match; launch plain
|
|
1318
|
-
r = subprocess.run(["open", "-a", "Google Chrome"], timeout=10, check=False, capture_output=True)
|
|
1319
|
-
return (None, base) if r.returncode == 0 else None
|
|
1320
|
-
if system == "Windows":
|
|
1321
|
-
# `start <name>` resolves browsers via App Paths without knowing the install dir
|
|
1322
|
-
subprocess.Popen(["cmd", "/c", "start", "", win_target or "chrome"] + profile_args, **ipc.spawn_kwargs())
|
|
1323
|
-
return None, base
|
|
1324
|
-
for cmd in posix_cmds or _DEFAULT_LAUNCH[1]:
|
|
1325
|
-
w = shutil.which(cmd)
|
|
1326
|
-
if w:
|
|
1327
|
-
process = subprocess.Popen(
|
|
1328
|
-
[w] + profile_args,
|
|
1329
|
-
stdout=subprocess.DEVNULL,
|
|
1330
|
-
stderr=subprocess.DEVNULL,
|
|
1331
|
-
**ipc.spawn_kwargs(),
|
|
1332
|
-
)
|
|
1333
|
-
return process, base
|
|
1334
|
-
return None
|
|
1335
|
-
except (OSError, subprocess.SubprocessError):
|
|
1336
|
-
return None
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
def _cleanup_unattached_browser_launch(launch):
|
|
1340
|
-
"""Stop a browser we launched when the daemon attached somewhere else."""
|
|
1341
|
-
if not launch:
|
|
1342
|
-
return
|
|
1343
|
-
process, profile = launch
|
|
1344
|
-
if process is None or profile is None or process.poll() is not None:
|
|
1345
|
-
return
|
|
1346
|
-
|
|
1347
|
-
from .daemon import _devtools_port_live
|
|
1348
|
-
|
|
1349
|
-
if _devtools_port_live(profile):
|
|
1350
|
-
return
|
|
1351
|
-
|
|
1352
|
-
import signal
|
|
1353
|
-
|
|
1354
|
-
try:
|
|
1355
|
-
if ipc.IS_WINDOWS:
|
|
1356
|
-
process.terminate()
|
|
1357
|
-
else:
|
|
1358
|
-
os.killpg(process.pid, signal.SIGTERM)
|
|
1359
|
-
except (OSError, subprocess.SubprocessError):
|
|
1360
|
-
pass
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
def _open_chrome_inspect():
|
|
1364
|
-
"""Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
|
|
1365
|
-
import platform, subprocess, webbrowser
|
|
1366
|
-
url = "chrome://inspect/#remote-debugging"
|
|
1367
|
-
if platform.system() == "Darwin":
|
|
1368
|
-
try:
|
|
1369
|
-
r = subprocess.run([
|
|
1370
|
-
"osascript",
|
|
1371
|
-
"-e", 'tell application "Google Chrome" to activate',
|
|
1372
|
-
"-e", f'tell application "Google Chrome" to open location "{url}"',
|
|
1373
|
-
], timeout=5, check=False, capture_output=True)
|
|
1374
|
-
if r.returncode == 0:
|
|
1375
|
-
return True
|
|
1376
|
-
except Exception:
|
|
1377
|
-
pass
|
|
1378
|
-
try:
|
|
1379
|
-
return bool(webbrowser.open(url, new=2))
|
|
1380
|
-
except Exception:
|
|
1381
|
-
return False
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
INSPECT_REOPEN_TTL = 180.0 # seconds open new chrome://inspect tab
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
def _open_chrome_inspect_once():
|
|
1388
|
-
"""Open chrome://inspect at most once per INSPECT_REOPEN_TTL across invocations"""
|
|
1389
|
-
marker = paths.inspect_marker()
|
|
1390
|
-
try:
|
|
1391
|
-
if time.time() - marker.stat().st_mtime < INSPECT_REOPEN_TTL:
|
|
1392
|
-
return
|
|
1393
|
-
except OSError:
|
|
1394
|
-
pass
|
|
1395
|
-
if not _open_chrome_inspect():
|
|
1396
|
-
return
|
|
1397
|
-
try:
|
|
1398
|
-
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
1399
|
-
marker.touch()
|
|
1400
|
-
except OSError:
|
|
1401
|
-
pass
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
def run_doctor():
|
|
1405
|
-
"""Read-only diagnostics. Exit 0 iff everything looks healthy."""
|
|
1406
|
-
import platform, sys
|
|
1407
|
-
cur = _version()
|
|
1408
|
-
mode = _install_mode()
|
|
1409
|
-
chrome = _chrome_running()
|
|
1410
|
-
daemon = daemon_alive()
|
|
1411
|
-
connections = browser_connections()
|
|
1412
|
-
try:
|
|
1413
|
-
auth_state = auth.auth_status()
|
|
1414
|
-
except (auth.AuthError, OSError) as e:
|
|
1415
|
-
auth_state = {"status": "error", "source": None, "reason": str(e)}
|
|
1416
|
-
cloud_auth = auth_state.get("status") == "authenticated"
|
|
1417
|
-
latest = _latest_release_tag()
|
|
1418
|
-
# Only claim an update when we know the installed version — `cur or "(unknown)"`
|
|
1419
|
-
# for display would otherwise be parsed as (0,) and flag every latest as newer.
|
|
1420
|
-
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
1421
|
-
cur_display = cur or "(unknown)"
|
|
1422
|
-
doc_url = _snap_linux_headless_doc_url()
|
|
1423
|
-
|
|
1424
|
-
def row(label, ok, detail=""):
|
|
1425
|
-
mark = "ok " if ok else "FAIL"
|
|
1426
|
-
print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
|
|
1427
|
-
|
|
1428
|
-
print("browser-harness doctor")
|
|
1429
|
-
print(f" platform {platform.system()} {platform.release()}")
|
|
1430
|
-
print(f" python {sys.version.split()[0]}")
|
|
1431
|
-
print(f" version {cur_display} ({mode})")
|
|
1432
|
-
if latest:
|
|
1433
|
-
print(f" latest release {latest}" + (" (update available)" if newer else ""))
|
|
1434
|
-
else:
|
|
1435
|
-
print(" latest release (could not reach PyPI)")
|
|
1436
|
-
if platform.system() == "Linux":
|
|
1437
|
-
bname, bpath = _doctor_probe_chrome_binary_for_snap()
|
|
1438
|
-
if bname and bpath and _is_snap_browser(bpath):
|
|
1439
|
-
print("[snap-detect]")
|
|
1440
|
-
print(f"Browser: {bname} (snap) — WARNING: Snap confinement prevents CDP binding.")
|
|
1441
|
-
print(f" Fix: Install Chrome natively (see docs/snap-linux-headless.md)")
|
|
1442
|
-
print(f" Docs: {doc_url}")
|
|
1443
|
-
row("chrome running", chrome, "" if chrome else "start chrome/edge")
|
|
1444
|
-
row("daemon alive", daemon, "" if daemon else "see install.md")
|
|
1445
|
-
row("active browser connections", bool(connections), str(len(connections)))
|
|
1446
|
-
for conn in connections:
|
|
1447
|
-
page = conn.get("page")
|
|
1448
|
-
if page:
|
|
1449
|
-
title = _doctor_short_text(page["title"])
|
|
1450
|
-
url = _doctor_short_text(page["url"])
|
|
1451
|
-
print(f" {conn['name']} — active page: {title} — {url}")
|
|
1452
|
-
else:
|
|
1453
|
-
print(f" {conn['name']} — active page: (no real page)")
|
|
1454
|
-
row("Browser Use cloud auth", cloud_auth, auth_state.get("source") or auth_state.get("reason") or "optional: browser-harness auth login")
|
|
1455
|
-
# Core health = chrome + daemon. Cloud auth is optional.
|
|
1456
|
-
return 0 if (chrome and daemon) else 1
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
def run_doctor_json(require_existing_daemon=False):
|
|
1460
|
-
"""Print a stable, non-networked runtime health report as JSON.
|
|
1461
|
-
|
|
1462
|
-
The strict mode is intended for trusted orchestrators that provision an
|
|
1463
|
-
exact named daemon. It checks only that selected daemon and its live CDP
|
|
1464
|
-
connection; it never starts, repairs, or discovers another daemon.
|
|
1465
|
-
"""
|
|
1466
|
-
strict = bool(require_existing_daemon)
|
|
1467
|
-
chrome = None if strict else _chrome_running()
|
|
1468
|
-
browser_ready = daemon_browser_ready(NAME)
|
|
1469
|
-
daemon = browser_ready or daemon_alive(NAME)
|
|
1470
|
-
healthy = (daemon and browser_ready) if strict else (browser_ready or (chrome and daemon))
|
|
1471
|
-
report = {
|
|
1472
|
-
"schema_version": 1,
|
|
1473
|
-
"healthy": healthy,
|
|
1474
|
-
"require_existing_daemon": strict,
|
|
1475
|
-
"version": _version() or None,
|
|
1476
|
-
"install_mode": _install_mode(),
|
|
1477
|
-
"chrome_running": chrome,
|
|
1478
|
-
"daemon": {
|
|
1479
|
-
"name": NAME,
|
|
1480
|
-
"alive": daemon,
|
|
1481
|
-
"browser_ready": browser_ready,
|
|
1482
|
-
},
|
|
1483
|
-
}
|
|
1484
|
-
print(json.dumps(report, sort_keys=True))
|
|
1485
|
-
return 0 if healthy else 1
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
def _prompt_yes(question, default_yes=True, yes=False):
|
|
1489
|
-
if yes:
|
|
1490
|
-
return True
|
|
1491
|
-
suffix = "[Y/n]" if default_yes else "[y/N]"
|
|
1492
|
-
try:
|
|
1493
|
-
ans = input(f"{question} {suffix} ").strip().lower()
|
|
1494
|
-
except EOFError:
|
|
1495
|
-
return default_yes
|
|
1496
|
-
if not ans:
|
|
1497
|
-
return default_yes
|
|
1498
|
-
return ans.startswith("y")
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
def _uv_manages_browser_harness():
|
|
1502
|
-
"""True when `uv tool list` shows browser-harness, i.e. `uv tool upgrade` owns it.
|
|
1503
|
-
|
|
1504
|
-
Unknown counts as managed: if uv cannot be run or its output is unreadable we
|
|
1505
|
-
stay quiet rather than tell a uv user to reinstall with pip.
|
|
1506
|
-
"""
|
|
1507
|
-
try:
|
|
1508
|
-
listed = subprocess.run(["uv", "tool", "list"], capture_output=True, text=True)
|
|
1509
|
-
except OSError:
|
|
1510
|
-
return True
|
|
1511
|
-
if listed.returncode != 0:
|
|
1512
|
-
return True
|
|
1513
|
-
# `uv tool list` prints one "name vX.Y.Z" line per tool, then its executables as
|
|
1514
|
-
# "- exe" lines. Match the entry name, so a tool merely containing our name (say
|
|
1515
|
-
# my-browser-harness-wrapper) cannot silence the hint for a real pip install.
|
|
1516
|
-
for line in (listed.stdout or "").splitlines():
|
|
1517
|
-
entry = line.strip()
|
|
1518
|
-
if entry.startswith("-"):
|
|
1519
|
-
continue
|
|
1520
|
-
if entry.split(" ", 1)[0] == "browser-harness":
|
|
1521
|
-
return True
|
|
1522
|
-
return False
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
def run_update(yes=False):
|
|
1526
|
-
"""Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
|
|
1527
|
-
|
|
1528
|
-
Exit 0 on success, non-zero on failure."""
|
|
1529
|
-
import subprocess, sys
|
|
1530
|
-
cur, latest, newer = check_for_update()
|
|
1531
|
-
# Only short-circuit as "up to date" when we actually know the installed
|
|
1532
|
-
# version. Otherwise `newer=False` just means "couldn't compare" — proceed.
|
|
1533
|
-
if cur and latest and not newer:
|
|
1534
|
-
print(f"browser-harness is up to date ({cur}).")
|
|
1535
|
-
return 0
|
|
1536
|
-
if cur and latest:
|
|
1537
|
-
print(f"updating browser-harness: {cur} -> {latest}")
|
|
1538
|
-
elif latest:
|
|
1539
|
-
print(f"installed version unknown; will try to update to {latest}.")
|
|
1540
|
-
else:
|
|
1541
|
-
print("could not reach PyPI; will try to update anyway.")
|
|
1542
|
-
|
|
1543
|
-
mode = _install_mode()
|
|
1544
|
-
if mode == "git":
|
|
1545
|
-
repo = _repo_dir()
|
|
1546
|
-
status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
|
|
1547
|
-
if status.returncode != 0:
|
|
1548
|
-
print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
|
|
1549
|
-
return 1
|
|
1550
|
-
if status.stdout.strip():
|
|
1551
|
-
print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
|
|
1552
|
-
print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
|
|
1553
|
-
return 1
|
|
1554
|
-
r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
|
|
1555
|
-
if r.returncode != 0:
|
|
1556
|
-
return r.returncode
|
|
1557
|
-
elif mode == "pypi":
|
|
1558
|
-
tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
|
|
1559
|
-
if tool_upgrade.returncode != 0:
|
|
1560
|
-
# `uv tool upgrade` only manages what `uv tool install` put there, so a pip
|
|
1561
|
-
# or pipx install fails here with "`browser-harness` is not installed" and
|
|
1562
|
-
# no way forward. Point only those users at the documented install: when the
|
|
1563
|
-
# tool IS uv-managed the failure is uv's own (offline, auth) and its message
|
|
1564
|
-
# already stands, so adding a pip hint there would just mislead.
|
|
1565
|
-
if not _uv_manages_browser_harness():
|
|
1566
|
-
print(
|
|
1567
|
-
"if you installed with pip or pipx, upgrade with: "
|
|
1568
|
-
"uv tool install --python 3.12 --upgrade --force browser-harness",
|
|
1569
|
-
file=sys.stderr,
|
|
1570
|
-
)
|
|
1571
|
-
return tool_upgrade.returncode
|
|
1572
|
-
else:
|
|
1573
|
-
print("unknown install mode; can't auto-update.", file=sys.stderr)
|
|
1574
|
-
return 1
|
|
1575
|
-
|
|
1576
|
-
# Invalidate banner/tag cache so the new version doesn't keep nagging.
|
|
1577
|
-
cache = _cache_read()
|
|
1578
|
-
cache.pop("banner_shown_on", None)
|
|
1579
|
-
_cache_write(cache)
|
|
1580
|
-
|
|
1581
|
-
if daemon_alive():
|
|
1582
|
-
if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
|
|
1583
|
-
restart_daemon()
|
|
1584
|
-
print("daemon stopped; it will auto-restart on next `browser-harness` call.")
|
|
1585
|
-
else:
|
|
1586
|
-
print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
|
|
1587
|
-
print("update complete.")
|
|
1588
|
-
return 0
|