browser-workspace 0.4.0
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/PRIVACY.md +21 -0
- package/README.md +138 -0
- package/SKILL.md +301 -0
- package/agent-workspace/agent_helpers.py +718 -0
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +75 -0
- package/extension/manifest.json +26 -0
- package/extension/service-worker.mjs +126 -0
- package/extension/workspace-manager.mjs +680 -0
- package/extension/workspace-page.mjs +7 -0
- package/extension/workspace.html +8 -0
- package/interaction-skills/connection.md +52 -0
- package/interaction-skills/cookies.md +3 -0
- package/interaction-skills/cross-origin-iframes.md +3 -0
- package/interaction-skills/dialogs.md +64 -0
- package/interaction-skills/downloads.md +3 -0
- package/interaction-skills/drag-and-drop.md +3 -0
- package/interaction-skills/dropdowns.md +3 -0
- package/interaction-skills/iframes.md +3 -0
- package/interaction-skills/network-requests.md +3 -0
- package/interaction-skills/print-as-pdf.md +3 -0
- package/interaction-skills/profile-sync.md +90 -0
- package/interaction-skills/screenshots.md +17 -0
- package/interaction-skills/scrolling.md +22 -0
- package/interaction-skills/shadow-dom.md +3 -0
- package/interaction-skills/tabs.md +73 -0
- package/interaction-skills/uploads.md +1 -0
- package/interaction-skills/viewport.md +16 -0
- package/licenses/browser-harness-MIT.txt +21 -0
- package/managed_chrome.py +244 -0
- package/node/admin.mjs +44 -0
- package/package.json +36 -0
- package/platform_runner.py +187 -0
- package/platforms/chatgpt/README.md +60 -0
- package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/platforms/chatgpt/actions/_readiness.py +52 -0
- package/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
- package/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/platforms/chatgpt/manifest.yaml +34 -0
- package/platforms/chatgpt/tests/test_contract.py +176 -0
- package/platforms/chrome-web-store/README.md +49 -0
- package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/platforms/chrome-web-store/manifest.yaml +33 -0
- package/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/platforms/microsoft-teams/README.md +32 -0
- package/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/platforms/microsoft-teams/manifest.yaml +25 -0
- package/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/platforms/tiktok/README.md +10 -0
- package/platforms/tiktok/actions/_post.py +180 -0
- package/platforms/tiktok/manifest.yaml +99 -0
- package/platforms/wechat-channels/README.md +10 -0
- package/platforms/wechat-channels/actions/_common.py +163 -0
- package/platforms/wechat-channels/actions/_manage.py +130 -0
- package/platforms/wechat-channels/actions/_post.py +232 -0
- package/platforms/wechat-channels/manifest.yaml +118 -0
- package/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/platforms/xhs/README.md +10 -0
- package/platforms/xhs/actions/_manage.py +355 -0
- package/platforms/xhs/actions/_post.py +192 -0
- package/platforms/xhs/manifest.yaml +98 -0
- package/platforms/youtube/README.md +10 -0
- package/platforms/youtube/actions/_post.py +100 -0
- package/platforms/youtube/manifest.yaml +94 -0
- package/pyproject.toml +29 -0
- package/session_client.py +97 -0
- package/session_daemon.py +325 -0
- package/src/browser_harness/__init__.py +2 -0
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +201 -0
- package/src/browser_harness/admin.py +1588 -0
- package/src/browser_harness/auth.py +546 -0
- package/src/browser_harness/daemon.py +894 -0
- package/src/browser_harness/helpers.py +668 -0
- package/src/browser_harness/macos.py +131 -0
- package/src/browser_harness/mcp_cli.py +16 -0
- package/src/browser_harness/paths.py +49 -0
- package/src/browser_harness/recorder.py +343 -0
- package/src/browser_harness/run.py +413 -0
- package/src/browser_harness/telemetry.py +308 -0
- package/src/browser_harness/video-template.html +970 -0
- package/src/browser_harness/video.py +749 -0
- package/src/browser_harness/video_render.py +524 -0
- package/src/browser_workspace.egg-info/PKG-INFO +10 -0
- package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
- package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
- package/src/browser_workspace.egg-info/requires.txt +5 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""CDP WS holder + IPC relay (Unix socket on POSIX, TCP loopback on Windows). One daemon per BU_NAME."""
|
|
2
|
+
import asyncio, json, os, platform, socket, sys, time, urllib.error, urllib.request
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
from collections import deque
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import _ipc as ipc
|
|
8
|
+
from . import auth
|
|
9
|
+
from . import paths
|
|
10
|
+
from cdp_use.client import CDPClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_env():
|
|
14
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
15
|
+
workspace = paths.workspace_dir()
|
|
16
|
+
for p in (repo_root / ".env", workspace / ".env"):
|
|
17
|
+
if not p.exists():
|
|
18
|
+
continue
|
|
19
|
+
_load_env_file(p)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_env_file(p):
|
|
23
|
+
for line in p.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
26
|
+
continue
|
|
27
|
+
k, v = line.split("=", 1)
|
|
28
|
+
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_load_env()
|
|
32
|
+
|
|
33
|
+
NAME = os.environ.get("BU_NAME", "default")
|
|
34
|
+
SOCK = ipc.sock_addr(NAME)
|
|
35
|
+
LOG = str(ipc.log_path(NAME))
|
|
36
|
+
PID = str(ipc.pid_path(NAME))
|
|
37
|
+
BUF = 500
|
|
38
|
+
_MAC_PROFILES = (
|
|
39
|
+
"Library/Application Support/Google/Chrome",
|
|
40
|
+
"Library/Application Support/Google/Chrome Canary",
|
|
41
|
+
"Library/Application Support/Comet",
|
|
42
|
+
"Library/Application Support/Arc/User Data",
|
|
43
|
+
"Library/Application Support/Dia/User Data",
|
|
44
|
+
"Library/Application Support/Microsoft Edge",
|
|
45
|
+
"Library/Application Support/Microsoft Edge Beta",
|
|
46
|
+
"Library/Application Support/Microsoft Edge Dev",
|
|
47
|
+
"Library/Application Support/Microsoft Edge Canary",
|
|
48
|
+
"Library/Application Support/BraveSoftware/Brave-Browser",
|
|
49
|
+
"Library/Application Support/BraveSoftware/Brave-Origin",
|
|
50
|
+
)
|
|
51
|
+
_LINUX_PROFILES = (
|
|
52
|
+
".config/google-chrome",
|
|
53
|
+
".config/chromium",
|
|
54
|
+
".config/chromium-browser",
|
|
55
|
+
".config/microsoft-edge",
|
|
56
|
+
".config/microsoft-edge-beta",
|
|
57
|
+
".config/microsoft-edge-dev",
|
|
58
|
+
".var/app/org.chromium.Chromium/config/chromium",
|
|
59
|
+
".var/app/com.google.Chrome/config/google-chrome",
|
|
60
|
+
".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser",
|
|
61
|
+
".var/app/com.microsoft.Edge/config/microsoft-edge",
|
|
62
|
+
)
|
|
63
|
+
_WINDOWS_PROFILES = ( # relative to %LOCALAPPDATA%; SxS = Canary channel
|
|
64
|
+
"Google/Chrome/User Data",
|
|
65
|
+
"Google/Chrome SxS/User Data",
|
|
66
|
+
"Google/Chrome Beta/User Data",
|
|
67
|
+
"Google/Chrome Dev/User Data",
|
|
68
|
+
"Chromium/User Data",
|
|
69
|
+
"Microsoft/Edge/User Data",
|
|
70
|
+
"Microsoft/Edge Beta/User Data",
|
|
71
|
+
"Microsoft/Edge Dev/User Data",
|
|
72
|
+
"Microsoft/Edge SxS/User Data",
|
|
73
|
+
"BraveSoftware/Brave-Browser/User Data",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _publish_own_pid(path=PID, pid=None):
|
|
78
|
+
"""Atomically retain or publish this daemon's PID record.
|
|
79
|
+
|
|
80
|
+
The parent may already have published a process-start fingerprint while
|
|
81
|
+
holding the spawn lock. Never truncate that record: another cold caller
|
|
82
|
+
must not observe an empty PID file and start a sibling daemon.
|
|
83
|
+
"""
|
|
84
|
+
pid = pid or os.getpid()
|
|
85
|
+
target = Path(path)
|
|
86
|
+
try:
|
|
87
|
+
published = target.read_text()
|
|
88
|
+
try:
|
|
89
|
+
published_pid = int(json.loads(published)["pid"])
|
|
90
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
91
|
+
published_pid = int(published.split()[0])
|
|
92
|
+
except (FileNotFoundError, OSError, ValueError, IndexError):
|
|
93
|
+
published = str(pid)
|
|
94
|
+
published_pid = pid
|
|
95
|
+
if published_pid != pid:
|
|
96
|
+
published = str(pid)
|
|
97
|
+
tmp = target.with_name(f"{target.name}.{pid}.tmp")
|
|
98
|
+
tmp.write_text(published)
|
|
99
|
+
os.replace(tmp, target)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def profile_dirs(system=None):
|
|
103
|
+
system = system or platform.system()
|
|
104
|
+
if system == "Windows":
|
|
105
|
+
local = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData/Local")
|
|
106
|
+
return [local / p for p in _WINDOWS_PROFILES]
|
|
107
|
+
if system == "Darwin":
|
|
108
|
+
return [Path.home() / p for p in _MAC_PROFILES]
|
|
109
|
+
return [Path.home() / p for p in _LINUX_PROFILES]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
PROFILES = profile_dirs()
|
|
113
|
+
INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:")
|
|
114
|
+
BU_API = "https://api.browser-use.com/api/v3"
|
|
115
|
+
REMOTE_ID = os.environ.get("BU_BROWSER_ID")
|
|
116
|
+
_REMOTE_STOPPED = False
|
|
117
|
+
BROWSER_KIND = "cloud" if REMOTE_ID else ("cdp" if (os.environ.get("BU_CDP_WS") or os.environ.get("BU_CDP_URL")) else "local")
|
|
118
|
+
# Chrome 144+ shows a per-connection popup, and the connection that raised it is
|
|
119
|
+
# the only thing keeping it on screen. There is deliberately no approval
|
|
120
|
+
# deadline: expiry would drop the sheet and make a later attempt create another
|
|
121
|
+
# connection and another prompt. Chrome/process death and explicit cancellation
|
|
122
|
+
# still terminate the pending connection.
|
|
123
|
+
LOCAL_HANDSHAKE_TIMEOUT = None
|
|
124
|
+
# How long get_ws_url() keeps waiting for DevToolsActivePort before giving up
|
|
125
|
+
NO_TOGGLE_GRACE = 3
|
|
126
|
+
TOGGLE_BOOT_GRACE = 12
|
|
127
|
+
# Cancellation should make an in-flight CDP call finish immediately. Keep the
|
|
128
|
+
# drain bounded anyway so shutdown fails closed if a client ignores cancellation.
|
|
129
|
+
RECOVERY_CANCEL_DRAIN_TIMEOUT = 2
|
|
130
|
+
TAB_MARKER_JS = "if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tab_marker_enabled():
|
|
134
|
+
"""Whether the cosmetic controlled-tab title marker should be added."""
|
|
135
|
+
return os.environ.get("BH_TAB_MARKER", "").strip().lower() not in {"0", "false", "no", "off"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _devtools_port_live(base):
|
|
139
|
+
"""True when something is listening on the profile's DevToolsActivePort port.
|
|
140
|
+
|
|
141
|
+
A stale file left behind by a closed browser must not count as a running
|
|
142
|
+
instance — it would route recovery to "click Allow" on a popup that can't
|
|
143
|
+
exist."""
|
|
144
|
+
try:
|
|
145
|
+
port = int((base / "DevToolsActivePort").read_text(encoding="utf-8", errors="replace").splitlines()[0].strip())
|
|
146
|
+
except (OSError, ValueError, IndexError):
|
|
147
|
+
return False
|
|
148
|
+
try:
|
|
149
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.5).close()
|
|
150
|
+
return True
|
|
151
|
+
except OSError:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def remote_debugging_user_enabled():
|
|
156
|
+
"""chrome://inspect's "Allow remote debugging" toggle
|
|
157
|
+
|
|
158
|
+
True only when a toggle-on profile also has a live DevTools port.
|
|
159
|
+
False if a profile records it off, None when no profile records it."""
|
|
160
|
+
seen = None
|
|
161
|
+
for base in PROFILES:
|
|
162
|
+
try:
|
|
163
|
+
state = json.loads((base / "Local State").read_text(encoding="utf-8", errors="replace"))
|
|
164
|
+
enabled = ((state.get("devtools") or {}).get("remote_debugging") or {}).get("user-enabled")
|
|
165
|
+
except (OSError, ValueError, AttributeError):
|
|
166
|
+
continue
|
|
167
|
+
if enabled is True and _devtools_port_live(base):
|
|
168
|
+
return True
|
|
169
|
+
if enabled is False:
|
|
170
|
+
seen = False
|
|
171
|
+
return seen
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def remote_debugging_toggle_profiles():
|
|
175
|
+
"""Profile dirs whose chrome://inspect toggle is recorded on in Local State"""
|
|
176
|
+
out = []
|
|
177
|
+
for base in PROFILES:
|
|
178
|
+
try:
|
|
179
|
+
state = json.loads((base / "Local State").read_text(encoding="utf-8", errors="replace"))
|
|
180
|
+
if ((state.get("devtools") or {}).get("remote_debugging") or {}).get("user-enabled") is True:
|
|
181
|
+
out.append(base)
|
|
182
|
+
except (OSError, ValueError, AttributeError):
|
|
183
|
+
continue
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def browser_running_for_profile(base):
|
|
188
|
+
"""True when a running browser instance holds this user-data-dir (POSIX)"""
|
|
189
|
+
try:
|
|
190
|
+
target = os.readlink(str(base / "SingletonLock"))
|
|
191
|
+
except OSError:
|
|
192
|
+
return False
|
|
193
|
+
try:
|
|
194
|
+
pid = int(target.rsplit("-", 1)[-1])
|
|
195
|
+
except ValueError:
|
|
196
|
+
return False
|
|
197
|
+
try:
|
|
198
|
+
os.kill(pid, 0)
|
|
199
|
+
return True
|
|
200
|
+
except ProcessLookupError:
|
|
201
|
+
return False
|
|
202
|
+
except OSError:
|
|
203
|
+
return True # pid exists but belongs to another user
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def supported_browser_running():
|
|
207
|
+
"""Is any browser whose profile we scan actually running?"""
|
|
208
|
+
if platform.system() == "Windows":
|
|
209
|
+
# Chromium on Windows uses a named mutex instead of SingletonLock —
|
|
210
|
+
import subprocess
|
|
211
|
+
try:
|
|
212
|
+
out = subprocess.check_output(["tasklist"], text=True, errors="replace", timeout=5).lower()
|
|
213
|
+
except Exception:
|
|
214
|
+
return True # can't tell — assume running so recovery stays on the popup/toggle path
|
|
215
|
+
return any(n in out for n in ("chrome.exe", "msedge.exe", "chromium.exe", "brave.exe", "helium.exe"))
|
|
216
|
+
return any(browser_running_for_profile(base) for base in PROFILES)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def log(msg):
|
|
220
|
+
open(LOG, "a", encoding="utf-8", errors="replace").write(f"{msg}\n")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _safe_connection_label(url):
|
|
224
|
+
"""Log only endpoint topology, never CDP credentials or provider session paths."""
|
|
225
|
+
try:
|
|
226
|
+
parsed = urlparse(url)
|
|
227
|
+
if not parsed.scheme or not parsed.hostname:
|
|
228
|
+
return "<redacted-cdp-endpoint>"
|
|
229
|
+
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
|
|
230
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
231
|
+
return f"{parsed.scheme}://{host}{port}"
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
return "<redacted-cdp-endpoint>"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def _silent(coro):
|
|
237
|
+
try:
|
|
238
|
+
await coro
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _ws_from_devtools_active_port(http_url: str) -> str | None:
|
|
244
|
+
"""When /json/version returns 404 (Chrome 147+ default profile), match DevToolsActivePort by port."""
|
|
245
|
+
p = urlparse(http_url)
|
|
246
|
+
want_port = str(p.port) if p.port else ""
|
|
247
|
+
if not want_port:
|
|
248
|
+
return None
|
|
249
|
+
host = p.hostname or "127.0.0.1"
|
|
250
|
+
if ":" in host: # urlparse strips IPv6 brackets; restore them for the ws:// URL
|
|
251
|
+
host = f"[{host}]"
|
|
252
|
+
for base in PROFILES:
|
|
253
|
+
try:
|
|
254
|
+
active = (base / "DevToolsActivePort").read_text(encoding="utf-8", errors="replace").splitlines()
|
|
255
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
256
|
+
continue
|
|
257
|
+
port = active[0].strip() if active else ""
|
|
258
|
+
ws_path = active[1].strip() if len(active) > 1 else ""
|
|
259
|
+
if port == want_port and ws_path:
|
|
260
|
+
return f"ws://{host}:{port}{ws_path}"
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_ws_url():
|
|
265
|
+
if url := os.environ.get("BU_CDP_WS"):
|
|
266
|
+
return url
|
|
267
|
+
if url := os.environ.get("BU_CDP_URL"):
|
|
268
|
+
# HTTP DevTools endpoint (e.g. http://127.0.0.1:9333) — resolve to ws via /json/version.
|
|
269
|
+
# Use this for a dedicated automation Chrome on a non-default profile, which avoids the
|
|
270
|
+
# M144 "Allow remote debugging" dialog and the M136 default-profile lockdown.
|
|
271
|
+
deadline = time.time() + 30
|
|
272
|
+
last_err = None
|
|
273
|
+
base_url = url.rstrip("/")
|
|
274
|
+
while time.time() < deadline:
|
|
275
|
+
try:
|
|
276
|
+
return json.loads(urllib.request.urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"]
|
|
277
|
+
except urllib.error.HTTPError as e:
|
|
278
|
+
last_err = e
|
|
279
|
+
if e.code == 403:
|
|
280
|
+
raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted")
|
|
281
|
+
if e.code == 404 and (ws := _ws_from_devtools_active_port(url)):
|
|
282
|
+
return ws
|
|
283
|
+
time.sleep(1)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
last_err = e
|
|
286
|
+
time.sleep(1)
|
|
287
|
+
hint = "is the dedicated automation Chrome running? Launch it with --remote-debugging-port=<port> --user-data-dir=<dedicated dir>"
|
|
288
|
+
if platform.system() == "Windows":
|
|
289
|
+
hint += "; on Windows also check that a firewall/antivirus isn't blocking localhost connections"
|
|
290
|
+
raise RuntimeError(f"BU_CDP_URL={url} unreachable after 30s: {last_err} -- {hint}")
|
|
291
|
+
deadline = time.time() + 30
|
|
292
|
+
next_liveness_check = 0.0
|
|
293
|
+
while time.time() < deadline:
|
|
294
|
+
for base in PROFILES:
|
|
295
|
+
try:
|
|
296
|
+
active = (base / "DevToolsActivePort").read_text(encoding="utf-8", errors="replace").splitlines()
|
|
297
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
298
|
+
continue
|
|
299
|
+
port = active[0].strip() if active else ""
|
|
300
|
+
ws_path = active[1].strip() if len(active) > 1 else ""
|
|
301
|
+
if not port:
|
|
302
|
+
continue
|
|
303
|
+
# Resolve the live WS URL via /json/version instead of trusting the path stored
|
|
304
|
+
# alongside the port in DevToolsActivePort: if Chrome was previously launched
|
|
305
|
+
# with a different --user-data-dir on the same port, that file is left behind
|
|
306
|
+
# with a stale browser UUID and the WS upgrade returns 404.
|
|
307
|
+
try:
|
|
308
|
+
return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"]
|
|
309
|
+
except urllib.error.HTTPError as e:
|
|
310
|
+
if e.code == 403:
|
|
311
|
+
raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted")
|
|
312
|
+
# Chrome 147+ disables /json/* HTTP discovery on the default user-data-dir;
|
|
313
|
+
# the ws path Chrome wrote to DevToolsActivePort still works.
|
|
314
|
+
if e.code == 404 and ws_path:
|
|
315
|
+
return f"ws://127.0.0.1:{port}{ws_path}"
|
|
316
|
+
except (OSError, KeyError, ValueError):
|
|
317
|
+
pass
|
|
318
|
+
# Closed browser leaves stale DevToolsActivePort files
|
|
319
|
+
now = time.time()
|
|
320
|
+
if now >= next_liveness_check:
|
|
321
|
+
if not supported_browser_running():
|
|
322
|
+
raise RuntimeError(
|
|
323
|
+
"chrome-not-running: no supported Chromium-family browser is running -- start Chrome, then retry"
|
|
324
|
+
)
|
|
325
|
+
next_liveness_check = now + 2
|
|
326
|
+
# The browser is running but the port isn't up; waiting 30s
|
|
327
|
+
grace = TOGGLE_BOOT_GRACE if remote_debugging_toggle_profiles() else NO_TOGGLE_GRACE
|
|
328
|
+
if now > deadline - 30 + grace:
|
|
329
|
+
break
|
|
330
|
+
time.sleep(0.2)
|
|
331
|
+
for probe_port in (9222, 9223):
|
|
332
|
+
try:
|
|
333
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r:
|
|
334
|
+
return json.loads(r.read())["webSocketDebuggerUrl"]
|
|
335
|
+
except urllib.error.HTTPError as e:
|
|
336
|
+
if e.code == 403:
|
|
337
|
+
raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted")
|
|
338
|
+
except (OSError, KeyError, ValueError):
|
|
339
|
+
continue
|
|
340
|
+
if remote_debugging_user_enabled() is False:
|
|
341
|
+
raise RuntimeError('remote debugging is turned off for this browser instance — enable chrome://inspect/#remote-debugging (tick "Allow remote debugging for this browser instance")')
|
|
342
|
+
raise RuntimeError(f"DevToolsActivePort not found in {[str(p) for p in PROFILES]} — enable chrome://inspect/#remote-debugging, or set BU_CDP_WS for a remote browser")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def stop_remote(strict=False):
|
|
346
|
+
global _REMOTE_STOPPED
|
|
347
|
+
if not REMOTE_ID:
|
|
348
|
+
return True
|
|
349
|
+
if _REMOTE_STOPPED:
|
|
350
|
+
return True
|
|
351
|
+
last_error = None
|
|
352
|
+
for attempt in range(3):
|
|
353
|
+
try:
|
|
354
|
+
key = auth.get_browser_use_api_key()
|
|
355
|
+
req = urllib.request.Request(
|
|
356
|
+
f"{BU_API}/browsers/{REMOTE_ID}",
|
|
357
|
+
data=json.dumps({"action": "stop"}).encode(),
|
|
358
|
+
method="PATCH",
|
|
359
|
+
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
|
|
360
|
+
)
|
|
361
|
+
urllib.request.urlopen(req, timeout=15).read()
|
|
362
|
+
_REMOTE_STOPPED = True
|
|
363
|
+
log(f"stopped remote browser {REMOTE_ID}")
|
|
364
|
+
return True
|
|
365
|
+
except Exception as e:
|
|
366
|
+
last_error = e
|
|
367
|
+
log(f"stop_remote attempt {attempt + 1}/3 failed ({REMOTE_ID}): {e}")
|
|
368
|
+
if attempt < 2:
|
|
369
|
+
time.sleep(0.5 * (attempt + 1))
|
|
370
|
+
if strict:
|
|
371
|
+
raise RuntimeError(f"failed to stop remote browser {REMOTE_ID}: {last_error}")
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def is_real_page(t):
|
|
376
|
+
return t["type"] == "page" and not t.get("url", "").startswith(INTERNAL)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def is_reusable_blank_page(t):
|
|
380
|
+
"""A plain about:blank tab that is safe to attach to and navigate"""
|
|
381
|
+
url = t.get("url", "")
|
|
382
|
+
return (
|
|
383
|
+
t["type"] == "page"
|
|
384
|
+
and (url == "about:blank" or url.startswith("about:blank#"))
|
|
385
|
+
and not t.get("title", "").startswith("Starting agent ")
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def is_inspect_tab(t):
|
|
390
|
+
"""A chrome://inspect tab — normally the one the permission flow opened"""
|
|
391
|
+
return t["type"] == "page" and t.get("url", "").startswith("chrome://inspect")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def harness_opened_inspect():
|
|
395
|
+
"""True when admin's recovery flow opened a chrome://inspect tab that is
|
|
396
|
+
still awaiting cleanup (the marker survives until the next connect)."""
|
|
397
|
+
try:
|
|
398
|
+
return paths.inspect_marker().exists()
|
|
399
|
+
except OSError:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def is_reusable_new_tab_page(t):
|
|
404
|
+
"""The browser's own New Tab Page, ex: from a fresh launch"""
|
|
405
|
+
return t["type"] == "page" and t.get("url", "").startswith(
|
|
406
|
+
("chrome://newtab", "chrome://new-tab-page", "edge://newtab", "about:newtab")
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class _PatientCDPClient(CDPClient):
|
|
411
|
+
"""CDPClient whose local Chrome approval handshake has no deadline."""
|
|
412
|
+
|
|
413
|
+
async def start(self):
|
|
414
|
+
import websockets
|
|
415
|
+
if self.ws is not None:
|
|
416
|
+
raise RuntimeError("Client is already started")
|
|
417
|
+
connect_kwargs = {"max_size": self.max_ws_frame_size, "open_timeout": LOCAL_HANDSHAKE_TIMEOUT}
|
|
418
|
+
if self.additional_headers:
|
|
419
|
+
connect_kwargs["additional_headers"] = self.additional_headers
|
|
420
|
+
self.ws = await websockets.connect(self.url, **connect_kwargs)
|
|
421
|
+
self._message_handler_task = asyncio.create_task(self._handle_messages())
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class Daemon:
|
|
425
|
+
def __init__(self):
|
|
426
|
+
self.cdp = None
|
|
427
|
+
self.session = None
|
|
428
|
+
self.target_id = None
|
|
429
|
+
self.dedicated_target_id = None
|
|
430
|
+
self._dedicated_target_lock = asyncio.Lock()
|
|
431
|
+
self._session_state_lock = asyncio.Lock()
|
|
432
|
+
self._active_recoveries = 0
|
|
433
|
+
self._recovery_tasks = set()
|
|
434
|
+
self._recoveries_idle = asyncio.Event()
|
|
435
|
+
self._recoveries_idle.set()
|
|
436
|
+
self._shutting_down = False
|
|
437
|
+
self._session_replacements = {}
|
|
438
|
+
self.events = deque(maxlen=BUF)
|
|
439
|
+
self.dialog = None
|
|
440
|
+
self.stop = None # asyncio.Event, set inside start()
|
|
441
|
+
|
|
442
|
+
async def attach_first_page(self, replaces_session=None, enable_domains=True):
|
|
443
|
+
"""Attach to a real page (or any page). Sets self.session. Returns attached target or None."""
|
|
444
|
+
targets = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
|
|
445
|
+
# Named daemons (BU_NAME != "default") share one browser with other
|
|
446
|
+
# daemons — attaching to the first page makes parallel daemons fight
|
|
447
|
+
# over a single tab (navigations clobber each other). Give each named
|
|
448
|
+
# daemon its own dedicated tab instead. REMOTE_ID (cloud) browsers are
|
|
449
|
+
# already exclusive to this daemon, so first-page attach stays.
|
|
450
|
+
if NAME != "default" and not REMOTE_ID:
|
|
451
|
+
# The permission recovery flow can leave chrome://inspect open.
|
|
452
|
+
# Clean it up before returning from this early path as well.
|
|
453
|
+
if BROWSER_KIND == "local":
|
|
454
|
+
await self._close_inspect_tabs(targets)
|
|
455
|
+
pages_by_id = {t["targetId"]: t for t in targets if t["type"] == "page"}
|
|
456
|
+
# A stale CDP session does not necessarily mean its tab disappeared.
|
|
457
|
+
# Reattach to the current tab first, then the daemon's dedicated tab.
|
|
458
|
+
page = pages_by_id.get(self.target_id) or pages_by_id.get(self.dedicated_target_id)
|
|
459
|
+
if page is None:
|
|
460
|
+
# Two stale IPC requests can recover concurrently. Recheck
|
|
461
|
+
# inside a narrow lock so they share one replacement tab.
|
|
462
|
+
async with self._dedicated_target_lock:
|
|
463
|
+
refreshed = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
|
|
464
|
+
pages_by_id = {t["targetId"]: t for t in refreshed if t["type"] == "page"}
|
|
465
|
+
page = pages_by_id.get(self.target_id) or pages_by_id.get(self.dedicated_target_id)
|
|
466
|
+
if page is None:
|
|
467
|
+
tid = (await self.cdp.send_raw(
|
|
468
|
+
"Target.createTarget", {"url": "about:blank", "background": True}
|
|
469
|
+
))["targetId"]
|
|
470
|
+
self.dedicated_target_id = tid
|
|
471
|
+
log(f"named daemon {NAME}: created dedicated tab ({tid})")
|
|
472
|
+
page = {"targetId": tid, "url": "about:blank", "type": "page"}
|
|
473
|
+
tid = page["targetId"]
|
|
474
|
+
self.session = (await self.cdp.send_raw(
|
|
475
|
+
"Target.attachToTarget", {"targetId": tid, "flatten": True}
|
|
476
|
+
))["sessionId"]
|
|
477
|
+
self._record_session_replacement(replaces_session, self.session)
|
|
478
|
+
self.target_id = tid
|
|
479
|
+
log(f"attached {tid} ({page.get('url','')[:80]}) session={self.session}")
|
|
480
|
+
if enable_domains:
|
|
481
|
+
await self._enable_default_domains(self.session)
|
|
482
|
+
return page
|
|
483
|
+
|
|
484
|
+
pages = [t for t in targets if is_real_page(t)]
|
|
485
|
+
if not pages:
|
|
486
|
+
# Fresh browser (ex: BU cloud) starts w about:blank; reuse it
|
|
487
|
+
pages = [t for t in targets if is_reusable_blank_page(t)]
|
|
488
|
+
if not pages:
|
|
489
|
+
# Freshly launched browser (ex: harness relaunching closed Chrome)
|
|
490
|
+
# starts with just the New Tab Page. Reuse it — creating about:blank
|
|
491
|
+
pages = [t for t in targets if is_reusable_new_tab_page(t)]
|
|
492
|
+
take_over = None
|
|
493
|
+
if not pages and harness_opened_inspect():
|
|
494
|
+
# After perms granted, only tab is often chrome://inspect
|
|
495
|
+
# Attach to it instead of creating a new about:blank
|
|
496
|
+
inspect_tabs = [t for t in targets if is_inspect_tab(t)]
|
|
497
|
+
if inspect_tabs:
|
|
498
|
+
pages = [inspect_tabs[0]]
|
|
499
|
+
take_over = inspect_tabs[0]["targetId"]
|
|
500
|
+
if not pages:
|
|
501
|
+
# No usable pages - create one instead of attaching to omnibox popup.
|
|
502
|
+
tid = (await self.cdp.send_raw(
|
|
503
|
+
"Target.createTarget", {"url": "about:blank", "background": True}
|
|
504
|
+
))["targetId"]
|
|
505
|
+
log(f"no real pages found, created about:blank ({tid})")
|
|
506
|
+
pages = [{"targetId": tid, "url": "about:blank", "type": "page"}]
|
|
507
|
+
self.session = (await self.cdp.send_raw(
|
|
508
|
+
"Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True}
|
|
509
|
+
))["sessionId"]
|
|
510
|
+
self._record_session_replacement(replaces_session, self.session)
|
|
511
|
+
self.target_id = pages[0]["targetId"]
|
|
512
|
+
log(f"attached {pages[0]['targetId']} ({pages[0].get('url','')[:80]}) session={self.session}")
|
|
513
|
+
if take_over:
|
|
514
|
+
try:
|
|
515
|
+
await self.cdp.send_raw("Page.navigate", {"url": "about:blank"}, session_id=self.session)
|
|
516
|
+
log(f"took over inspect tab {take_over} -> about:blank")
|
|
517
|
+
except Exception as e:
|
|
518
|
+
log(f"take over inspect tab {take_over}: {e}")
|
|
519
|
+
if BROWSER_KIND == "local":
|
|
520
|
+
await self._close_inspect_tabs(targets)
|
|
521
|
+
if enable_domains:
|
|
522
|
+
await self._enable_default_domains(self.session)
|
|
523
|
+
return pages[0]
|
|
524
|
+
|
|
525
|
+
async def _close_inspect_tabs(self, targets):
|
|
526
|
+
"""Close chrome://inspect tabs left open by the permission recovery flow"""
|
|
527
|
+
if not harness_opened_inspect():
|
|
528
|
+
return
|
|
529
|
+
for t in targets:
|
|
530
|
+
if t["targetId"] != self.target_id and is_inspect_tab(t):
|
|
531
|
+
try:
|
|
532
|
+
await self.cdp.send_raw("Target.closeTarget", {"targetId": t["targetId"]})
|
|
533
|
+
log(f"closed leftover chrome://inspect tab {t['targetId']}")
|
|
534
|
+
except Exception as e:
|
|
535
|
+
log(f"close inspect tab {t['targetId']}: {e}")
|
|
536
|
+
try:
|
|
537
|
+
paths.inspect_marker().unlink()
|
|
538
|
+
except OSError:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
async def _enable_default_domains(self, session_id):
|
|
542
|
+
"""Enable Page/DOM/Runtime/Network on a CDP session.
|
|
543
|
+
|
|
544
|
+
Used by both initial attach and set_session (called after switch_tab/
|
|
545
|
+
new_tab). Without this, helpers that depend on Network.* events —
|
|
546
|
+
notably wait_for_network_idle() — silently stop receiving events
|
|
547
|
+
after a tab switch, because each fresh CDP session starts with all
|
|
548
|
+
domains disabled.
|
|
549
|
+
|
|
550
|
+
Runs the four enables in parallel via gather so the worst-case time is
|
|
551
|
+
bounded by a single CDP round trip rather than four sequential ones —
|
|
552
|
+
important on the set_session path, where the helper's IPC socket has
|
|
553
|
+
a 5s read timeout.
|
|
554
|
+
"""
|
|
555
|
+
async def enable_one(d):
|
|
556
|
+
try:
|
|
557
|
+
await asyncio.wait_for(
|
|
558
|
+
self.cdp.send_raw(f"{d}.enable", session_id=session_id),
|
|
559
|
+
timeout=4,
|
|
560
|
+
)
|
|
561
|
+
except Exception as e:
|
|
562
|
+
log(f"enable {d} on {session_id}: {e}")
|
|
563
|
+
await asyncio.gather(*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")))
|
|
564
|
+
|
|
565
|
+
def _record_session_replacement(self, stale_session, replacement_session):
|
|
566
|
+
"""Remember which recovered session still controls the same tab."""
|
|
567
|
+
if not stale_session or not replacement_session or stale_session == replacement_session:
|
|
568
|
+
return
|
|
569
|
+
# Preserve chains so requests delayed across multiple recoveries still
|
|
570
|
+
# land on their original tab, never whichever tab is current now.
|
|
571
|
+
for source, replacement in list(self._session_replacements.items()):
|
|
572
|
+
if replacement == stale_session:
|
|
573
|
+
self._session_replacements[source] = replacement_session
|
|
574
|
+
self._session_replacements[stale_session] = replacement_session
|
|
575
|
+
while len(self._session_replacements) > 32:
|
|
576
|
+
self._session_replacements.pop(next(iter(self._session_replacements)))
|
|
577
|
+
|
|
578
|
+
def _begin_recovery(self):
|
|
579
|
+
"""Register the current IPC handler unless shutdown has started."""
|
|
580
|
+
if self._shutting_down:
|
|
581
|
+
return None
|
|
582
|
+
task = asyncio.current_task()
|
|
583
|
+
if task is None:
|
|
584
|
+
return None
|
|
585
|
+
self._recovery_tasks.add(task)
|
|
586
|
+
self._active_recoveries += 1
|
|
587
|
+
self._recoveries_idle.clear()
|
|
588
|
+
return task
|
|
589
|
+
|
|
590
|
+
def _finish_recovery(self, task):
|
|
591
|
+
self._recovery_tasks.discard(task)
|
|
592
|
+
self._active_recoveries -= 1
|
|
593
|
+
if self._active_recoveries == 0:
|
|
594
|
+
self._recoveries_idle.set()
|
|
595
|
+
|
|
596
|
+
async def _cancel_and_drain_recoveries(self):
|
|
597
|
+
"""Cancel active stale-session handlers and wait a bounded time."""
|
|
598
|
+
current = asyncio.current_task()
|
|
599
|
+
tasks = [
|
|
600
|
+
task for task in self._recovery_tasks
|
|
601
|
+
if task is not current and not task.done()
|
|
602
|
+
]
|
|
603
|
+
for task in tasks:
|
|
604
|
+
task.cancel()
|
|
605
|
+
if tasks:
|
|
606
|
+
_done, pending = await asyncio.wait(
|
|
607
|
+
tasks, timeout=RECOVERY_CANCEL_DRAIN_TIMEOUT
|
|
608
|
+
)
|
|
609
|
+
if pending:
|
|
610
|
+
return False
|
|
611
|
+
return self._recoveries_idle.is_set()
|
|
612
|
+
|
|
613
|
+
def _schedule_tab_marker(self, session_id):
|
|
614
|
+
"""Mark the controlled tab without extending the synchronous IPC path."""
|
|
615
|
+
if not tab_marker_enabled():
|
|
616
|
+
return None
|
|
617
|
+
return asyncio.create_task(_silent(asyncio.wait_for(
|
|
618
|
+
self.cdp.send_raw(
|
|
619
|
+
"Runtime.evaluate",
|
|
620
|
+
{"expression": TAB_MARKER_JS},
|
|
621
|
+
session_id=session_id,
|
|
622
|
+
),
|
|
623
|
+
timeout=2,
|
|
624
|
+
)))
|
|
625
|
+
|
|
626
|
+
def _record_event(self, method, params, session_id=None):
|
|
627
|
+
self.events.append({"method": method, "params": params, "session_id": session_id})
|
|
628
|
+
if method == "Page.javascriptDialogOpening":
|
|
629
|
+
self.dialog = params
|
|
630
|
+
elif method == "Page.javascriptDialogClosed":
|
|
631
|
+
self.dialog = None
|
|
632
|
+
elif method in ("Page.loadEventFired", "Page.domContentEventFired"):
|
|
633
|
+
self._schedule_tab_marker(self.session)
|
|
634
|
+
|
|
635
|
+
async def start(self):
|
|
636
|
+
self.stop = asyncio.Event()
|
|
637
|
+
url = get_ws_url()
|
|
638
|
+
log(f"connecting to {_safe_connection_label(url)}")
|
|
639
|
+
self.cdp = _PatientCDPClient(url) if BROWSER_KIND == "local" else CDPClient(url)
|
|
640
|
+
if BROWSER_KIND == "local":
|
|
641
|
+
# Allow while this handshake is still parked on the popup
|
|
642
|
+
log("handshake-wait: if Chrome shows an 'Allow remote debugging?' popup, click Allow")
|
|
643
|
+
try:
|
|
644
|
+
await self.cdp.start()
|
|
645
|
+
except Exception as e:
|
|
646
|
+
if os.environ.get("BU_CDP_WS"):
|
|
647
|
+
raise RuntimeError(
|
|
648
|
+
f"CDP WS handshake failed: {e} -- remote browser WebSocket connection failed. "
|
|
649
|
+
"This can happen when network policy blocks the connection, the WS URL is wrong or expired, or the remote endpoint is down. "
|
|
650
|
+
"If you use Browser Use cloud, verify auth and get a fresh URL via start_remote_daemon()."
|
|
651
|
+
)
|
|
652
|
+
if BROWSER_KIND == "local" and ("timed out" in str(e).lower() or "403" in str(e)) and remote_debugging_user_enabled():
|
|
653
|
+
raise RuntimeError(
|
|
654
|
+
"permission-blocked: Chrome did not approve the remote debugging connection; "
|
|
655
|
+
"browser-harness did not retry or create another connection"
|
|
656
|
+
)
|
|
657
|
+
raise RuntimeError(f"CDP WS handshake failed: {e} -- click Allow in Chrome if prompted, then retry")
|
|
658
|
+
await self.attach_first_page()
|
|
659
|
+
orig = self.cdp._event_registry.handle_event
|
|
660
|
+
async def tap(method, params, session_id=None):
|
|
661
|
+
self._record_event(method, params, session_id)
|
|
662
|
+
return await orig(method, params, session_id)
|
|
663
|
+
self.cdp._event_registry.handle_event = tap
|
|
664
|
+
|
|
665
|
+
async def handle(self, req):
|
|
666
|
+
# Token guard for Windows TCP loopback: any local process can otherwise
|
|
667
|
+
# connect and issue CDP commands. expected_token() is None on POSIX so
|
|
668
|
+
# this check is a no-op there (AF_UNIX + chmod 600 is the boundary).
|
|
669
|
+
expected = ipc.expected_token()
|
|
670
|
+
if expected is not None and req.get("token") != expected:
|
|
671
|
+
return {"error": "unauthorized"}
|
|
672
|
+
meta = req.get("meta")
|
|
673
|
+
# Liveness probe — lets clients confirm the listener is actually this
|
|
674
|
+
# daemon and not an unrelated process that reused our port post-crash.
|
|
675
|
+
# `pid` lets restart_daemon() verify the live daemon's identity before
|
|
676
|
+
# signaling — protects against SIGTERM-by-stale-pid-file after PID reuse.
|
|
677
|
+
if meta == "ping": return {"pong": True, "pid": os.getpid(), "browser_kind": BROWSER_KIND}
|
|
678
|
+
if meta == "drain_events":
|
|
679
|
+
out = list(self.events); self.events.clear()
|
|
680
|
+
return {"events": out}
|
|
681
|
+
if meta == "session": return {"session_id": self.session}
|
|
682
|
+
if meta == "current_tab":
|
|
683
|
+
# Resolve the attached page's target info server-side. Helpers can't
|
|
684
|
+
# send Target.getTargetInfo themselves: daemon strips session_id for
|
|
685
|
+
# any Target.* method (browser-level call), and without a targetId
|
|
686
|
+
# Chrome silently returns the *browser* target.
|
|
687
|
+
if not self.target_id:
|
|
688
|
+
return {"error": "not_attached"}
|
|
689
|
+
try:
|
|
690
|
+
info = (await self.cdp.send_raw("Target.getTargetInfo", {"targetId": self.target_id}))["targetInfo"]
|
|
691
|
+
except Exception:
|
|
692
|
+
return {"error": "cdp_disconnected"}
|
|
693
|
+
return {"targetId": info.get("targetId"), "url": info.get("url", ""), "title": info.get("title", "")}
|
|
694
|
+
if meta == "connection_status":
|
|
695
|
+
if not self.target_id:
|
|
696
|
+
return {"error": "not_attached"}
|
|
697
|
+
try:
|
|
698
|
+
info = (await self.cdp.send_raw("Target.getTargetInfo", {"targetId": self.target_id}))["targetInfo"]
|
|
699
|
+
except Exception:
|
|
700
|
+
return {"error": "cdp_disconnected"}
|
|
701
|
+
page = None
|
|
702
|
+
if is_real_page(info):
|
|
703
|
+
page = {
|
|
704
|
+
"targetId": info.get("targetId"),
|
|
705
|
+
"title": info.get("title") or "(untitled)",
|
|
706
|
+
"url": info.get("url") or "",
|
|
707
|
+
}
|
|
708
|
+
return {"target_id": self.target_id, "session_id": self.session, "page": page}
|
|
709
|
+
if meta == "set_session":
|
|
710
|
+
async with self._session_state_lock:
|
|
711
|
+
old_session = self.session
|
|
712
|
+
self.session = req.get("session_id")
|
|
713
|
+
self.target_id = req.get("target_id") or self.target_id
|
|
714
|
+
new_session = self.session
|
|
715
|
+
# Run the old-session Network.disable (defense in depth — keeps
|
|
716
|
+
# background-tab traffic out of the global event buffer; the
|
|
717
|
+
# consumer-side filter in wait_for_network_idle is the actual
|
|
718
|
+
# correctness gate) in parallel with the four enables on the new
|
|
719
|
+
# session. Different sessions, independent CDP requests. Keeps
|
|
720
|
+
# the synchronous reply under the helper's 5s IPC read timeout
|
|
721
|
+
# even on a remote daemon — sequentially these would have stacked
|
|
722
|
+
# to ~22s worst case.
|
|
723
|
+
tasks = []
|
|
724
|
+
if old_session and old_session != new_session:
|
|
725
|
+
async def disable_old():
|
|
726
|
+
try:
|
|
727
|
+
await asyncio.wait_for(
|
|
728
|
+
self.cdp.send_raw("Network.disable", session_id=old_session),
|
|
729
|
+
timeout=2,
|
|
730
|
+
)
|
|
731
|
+
except Exception: pass
|
|
732
|
+
tasks.append(disable_old())
|
|
733
|
+
tasks.append(self._enable_default_domains(new_session))
|
|
734
|
+
await asyncio.gather(*tasks)
|
|
735
|
+
# 🐴 tab-marker title prefix is purely cosmetic — fire-and-forget so
|
|
736
|
+
# it doesn't add to the synchronous IPC budget.
|
|
737
|
+
self._schedule_tab_marker(new_session)
|
|
738
|
+
return {"session_id": new_session}
|
|
739
|
+
if meta == "pending_dialog": return {"dialog": self.dialog}
|
|
740
|
+
if meta == "shutdown":
|
|
741
|
+
# Flip the barrier synchronously with recovery registration, then
|
|
742
|
+
# cancel/drain existing handlers. In particular, a CDP replay that
|
|
743
|
+
# never answers must not prevent Cloud cleanup from being attempted.
|
|
744
|
+
if self._shutting_down:
|
|
745
|
+
return {"error": "shutdown already in progress"}
|
|
746
|
+
self._shutting_down = True
|
|
747
|
+
if not await self._cancel_and_drain_recoveries():
|
|
748
|
+
# Preserve the daemon as a retryable cleanup authority. The
|
|
749
|
+
# strict caller will leave its endpoint and PID file intact.
|
|
750
|
+
self._shutting_down = False
|
|
751
|
+
return {"error": "stale-session recovery did not stop"}
|
|
752
|
+
try:
|
|
753
|
+
stop_remote(strict=True)
|
|
754
|
+
except Exception as e:
|
|
755
|
+
# A failed Cloud stop must leave the daemon usable so a later
|
|
756
|
+
# shutdown request can retry the billable-browser cleanup.
|
|
757
|
+
async with self._session_state_lock:
|
|
758
|
+
self._shutting_down = False
|
|
759
|
+
return {"error": str(e)}
|
|
760
|
+
self.stop.set()
|
|
761
|
+
return {"ok": True}
|
|
762
|
+
|
|
763
|
+
method = req["method"]
|
|
764
|
+
params = req.get("params") or {}
|
|
765
|
+
# Browser-level Target.* calls must not use a session (stale or otherwise).
|
|
766
|
+
# For everything else, explicit session in req wins; else default.
|
|
767
|
+
sid = None if method.startswith("Target.") else (req.get("session_id") or self.session)
|
|
768
|
+
try:
|
|
769
|
+
return {"result": await self.cdp.send_raw(method, params, session_id=sid)}
|
|
770
|
+
except Exception as e:
|
|
771
|
+
msg = str(e)
|
|
772
|
+
if "Session with given id not found" in msg and sid:
|
|
773
|
+
# Explicit session callers asked for that exact session; do not
|
|
774
|
+
# silently redirect them to the daemon's current tab.
|
|
775
|
+
if req.get("session_id"):
|
|
776
|
+
return {"error": msg}
|
|
777
|
+
recovery_task = self._begin_recovery()
|
|
778
|
+
if recovery_task is None:
|
|
779
|
+
return {"error": "daemon is shutting down"}
|
|
780
|
+
try:
|
|
781
|
+
recovered_here = False
|
|
782
|
+
async with self._session_state_lock:
|
|
783
|
+
if self._shutting_down:
|
|
784
|
+
return {"error": "daemon is shutting down"}
|
|
785
|
+
replacement_session = self._session_replacements.get(sid)
|
|
786
|
+
if replacement_session is None and sid == self.session:
|
|
787
|
+
log(f"stale session {sid}, re-attaching")
|
|
788
|
+
if not await self.attach_first_page(
|
|
789
|
+
replaces_session=sid, enable_domains=False
|
|
790
|
+
):
|
|
791
|
+
return {"error": msg}
|
|
792
|
+
replacement_session = self._session_replacements.get(sid)
|
|
793
|
+
recovered_here = replacement_session is not None
|
|
794
|
+
if recovered_here:
|
|
795
|
+
await self._enable_default_domains(replacement_session)
|
|
796
|
+
# Retry only on a session known to replace this exact stale
|
|
797
|
+
# session. self.session may instead have changed because the
|
|
798
|
+
# user deliberately switched tabs while this request waited.
|
|
799
|
+
if replacement_session:
|
|
800
|
+
try:
|
|
801
|
+
return {"result": await self.cdp.send_raw(
|
|
802
|
+
method, params, session_id=replacement_session
|
|
803
|
+
)}
|
|
804
|
+
except Exception as retry_error:
|
|
805
|
+
return {"error": str(retry_error)}
|
|
806
|
+
finally:
|
|
807
|
+
self._finish_recovery(recovery_task)
|
|
808
|
+
return {"error": msg}
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
async def serve(d):
|
|
812
|
+
async def handler(reader, writer):
|
|
813
|
+
try:
|
|
814
|
+
line = await reader.readline()
|
|
815
|
+
if not line: return
|
|
816
|
+
resp = await d.handle(json.loads(line))
|
|
817
|
+
writer.write((json.dumps(resp, default=str) + "\n").encode())
|
|
818
|
+
await writer.drain()
|
|
819
|
+
except Exception as e:
|
|
820
|
+
log(f"conn: {e}")
|
|
821
|
+
try:
|
|
822
|
+
writer.write((json.dumps({"error": str(e)}) + "\n").encode())
|
|
823
|
+
await writer.drain()
|
|
824
|
+
except Exception:
|
|
825
|
+
pass
|
|
826
|
+
finally:
|
|
827
|
+
writer.close()
|
|
828
|
+
|
|
829
|
+
serve_task = asyncio.create_task(ipc.serve(NAME, handler))
|
|
830
|
+
stop_task = asyncio.create_task(d.stop.wait())
|
|
831
|
+
await asyncio.sleep(0.05) # let serve() bind so sock_addr() resolves to the live endpoint
|
|
832
|
+
log(f"listening on {ipc.sock_addr(NAME)} (name={NAME}, remote={REMOTE_ID or 'local'})")
|
|
833
|
+
try:
|
|
834
|
+
await asyncio.wait({serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
835
|
+
if serve_task.done(): await serve_task # surfaces a serve crash
|
|
836
|
+
finally:
|
|
837
|
+
for t in (serve_task, stop_task):
|
|
838
|
+
t.cancel()
|
|
839
|
+
try: await t
|
|
840
|
+
except (asyncio.CancelledError, Exception): pass
|
|
841
|
+
# A server crash/cancellation does not pass through meta=shutdown.
|
|
842
|
+
# Establish the same recovery barrier before touching owned targets.
|
|
843
|
+
d._shutting_down = True
|
|
844
|
+
recoveries_drained = await d._cancel_and_drain_recoveries()
|
|
845
|
+
# Named non-cloud daemons create one dedicated background tab. Shutdown
|
|
846
|
+
# has blocked new recovery and drained active recovery before setting
|
|
847
|
+
# d.stop (or finalization established the barrier after a server crash).
|
|
848
|
+
# Take the same locks, in the same order as recovery, so cleanup closes
|
|
849
|
+
# exactly the final daemon-owned target and never races creation.
|
|
850
|
+
if recoveries_drained:
|
|
851
|
+
async with d._session_state_lock:
|
|
852
|
+
async with d._dedicated_target_lock:
|
|
853
|
+
if d.dedicated_target_id and d.cdp:
|
|
854
|
+
try:
|
|
855
|
+
await d.cdp.send_raw(
|
|
856
|
+
"Target.closeTarget", {"targetId": d.dedicated_target_id}
|
|
857
|
+
)
|
|
858
|
+
d.dedicated_target_id = None
|
|
859
|
+
except Exception as e:
|
|
860
|
+
log(f"close dedicated tab on shutdown: {e}")
|
|
861
|
+
else:
|
|
862
|
+
log("skip dedicated-tab cleanup: stale-session recovery did not stop")
|
|
863
|
+
ipc.cleanup_endpoint(NAME)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
async def main():
|
|
867
|
+
d = Daemon()
|
|
868
|
+
await d.start()
|
|
869
|
+
await serve(d)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def already_running():
|
|
873
|
+
# Ping handshake (not a bare connect) so a stale .port file + port reuse
|
|
874
|
+
# after a daemon crash doesn't make us mistake an unrelated listener for ours.
|
|
875
|
+
return ipc.ping(NAME, timeout=1.0)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
if __name__ == "__main__":
|
|
879
|
+
if already_running():
|
|
880
|
+
print(f"daemon already running on {SOCK}", file=sys.stderr)
|
|
881
|
+
sys.exit(0)
|
|
882
|
+
open(LOG, "w").close()
|
|
883
|
+
_publish_own_pid()
|
|
884
|
+
try:
|
|
885
|
+
asyncio.run(main())
|
|
886
|
+
except KeyboardInterrupt:
|
|
887
|
+
pass
|
|
888
|
+
except Exception as e:
|
|
889
|
+
log(f"fatal: {e}")
|
|
890
|
+
sys.exit(1)
|
|
891
|
+
finally:
|
|
892
|
+
stop_remote()
|
|
893
|
+
try: os.unlink(PID)
|
|
894
|
+
except FileNotFoundError: pass
|