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.
Files changed (107) hide show
  1. package/README.md +15 -1
  2. package/SKILL.md +22 -21
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/package.json +1 -1
  5. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  6. package/platforms/chatgpt/tests/test_contract.py +7 -0
  7. package/pyproject.toml +6 -2
  8. package/session_daemon.py +91 -3
  9. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  10. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  11. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  12. package/src/browser_workspace.egg-info/requires.txt +1 -0
  13. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  14. package/src/browser_workspace_app/__init__.py +1 -0
  15. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  16. package/src/browser_workspace_app/cli.py +236 -0
  17. package/src/browser_workspace_app/extension/manifest.json +26 -0
  18. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  19. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  20. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  21. package/src/browser_workspace_app/extension/workspace.html +8 -0
  22. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  23. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  24. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  25. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  26. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  27. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  28. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  29. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  30. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  31. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  32. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  33. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  34. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  35. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  36. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  37. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  38. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  39. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  40. package/src/browser_workspace_app/managed_chrome.py +244 -0
  41. package/src/browser_workspace_app/node/admin.mjs +44 -0
  42. package/src/browser_workspace_app/package.json +36 -0
  43. package/src/browser_workspace_app/platform_runner.py +204 -0
  44. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  45. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  46. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  47. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  48. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  49. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  50. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  51. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  52. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  53. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  57. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  58. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  59. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  60. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  61. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  62. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  63. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  64. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  65. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  66. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  67. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  68. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  69. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  70. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  71. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  72. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  73. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  74. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  75. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  76. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  77. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  78. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  79. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  80. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  81. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  82. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  83. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  84. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  85. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  86. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  87. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  88. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  89. package/src/browser_workspace_app/pyproject.toml +33 -0
  90. package/src/browser_workspace_app/session_client.py +107 -0
  91. package/src/browser_workspace_app/session_daemon.py +431 -0
  92. package/src/browser_harness/__init__.py +0 -2
  93. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  94. package/src/browser_harness/_ipc.py +0 -201
  95. package/src/browser_harness/admin.py +0 -1588
  96. package/src/browser_harness/auth.py +0 -546
  97. package/src/browser_harness/daemon.py +0 -894
  98. package/src/browser_harness/helpers.py +0 -668
  99. package/src/browser_harness/macos.py +0 -131
  100. package/src/browser_harness/mcp_cli.py +0 -16
  101. package/src/browser_harness/paths.py +0 -49
  102. package/src/browser_harness/recorder.py +0 -343
  103. package/src/browser_harness/run.py +0 -413
  104. package/src/browser_harness/telemetry.py +0 -308
  105. package/src/browser_harness/video-template.html +0 -970
  106. package/src/browser_harness/video.py +0 -749
  107. package/src/browser_harness/video_render.py +0 -524
@@ -1,894 +0,0 @@
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