browser-workspace 0.4.1 → 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 (117) hide show
  1. package/README.md +16 -2
  2. package/SKILL.md +60 -37
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/bin/browser-workspace +174 -13
  5. package/package.json +1 -1
  6. package/platform_runner.py +23 -6
  7. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  8. package/platforms/chatgpt/tests/test_contract.py +7 -0
  9. package/platforms/wechat-channels/actions/_common.py +1 -1
  10. package/platforms/wechat-channels/actions/_post.py +37 -38
  11. package/platforms/wechat-channels/manifest.yaml +19 -13
  12. package/platforms/xhs/actions/_post.py +109 -20
  13. package/platforms/xhs/manifest.yaml +14 -5
  14. package/platforms/xhs/tests/test_contract.py +17 -0
  15. package/platforms/youtube/actions/_post.py +7 -4
  16. package/pyproject.toml +6 -2
  17. package/session_client.py +21 -11
  18. package/session_daemon.py +115 -9
  19. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  20. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  21. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  22. package/src/browser_workspace.egg-info/requires.txt +1 -0
  23. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  24. package/src/browser_workspace_app/__init__.py +1 -0
  25. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  26. package/src/browser_workspace_app/cli.py +236 -0
  27. package/src/browser_workspace_app/extension/manifest.json +26 -0
  28. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  29. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  30. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  31. package/src/browser_workspace_app/extension/workspace.html +8 -0
  32. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  33. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  34. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  35. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  36. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  37. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  38. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  39. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  40. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  41. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  42. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  43. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  44. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  45. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  46. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  47. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  48. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  49. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  50. package/src/browser_workspace_app/managed_chrome.py +244 -0
  51. package/src/browser_workspace_app/node/admin.mjs +44 -0
  52. package/src/browser_workspace_app/package.json +36 -0
  53. package/src/browser_workspace_app/platform_runner.py +204 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  57. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  58. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  59. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  60. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  61. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  62. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  63. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  64. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  65. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  66. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  67. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  68. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  69. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  70. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  71. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  72. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  73. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  74. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  75. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  76. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  77. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  78. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  79. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  80. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  81. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  82. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  83. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  84. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  85. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  86. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  87. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  88. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  89. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  90. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  91. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  92. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  93. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  94. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  95. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  96. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  97. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  98. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  99. package/src/browser_workspace_app/pyproject.toml +33 -0
  100. package/src/browser_workspace_app/session_client.py +107 -0
  101. package/src/browser_workspace_app/session_daemon.py +431 -0
  102. package/src/browser_harness/__init__.py +0 -2
  103. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  104. package/src/browser_harness/_ipc.py +0 -201
  105. package/src/browser_harness/admin.py +0 -1588
  106. package/src/browser_harness/auth.py +0 -546
  107. package/src/browser_harness/daemon.py +0 -894
  108. package/src/browser_harness/helpers.py +0 -668
  109. package/src/browser_harness/macos.py +0 -131
  110. package/src/browser_harness/mcp_cli.py +0 -16
  111. package/src/browser_harness/paths.py +0 -49
  112. package/src/browser_harness/recorder.py +0 -343
  113. package/src/browser_harness/run.py +0 -413
  114. package/src/browser_harness/telemetry.py +0 -308
  115. package/src/browser_harness/video-template.html +0 -970
  116. package/src/browser_harness/video.py +0 -749
  117. package/src/browser_harness/video_render.py +0 -524
@@ -1,668 +0,0 @@
1
- """Browser control via CDP.
2
-
3
- Core helpers live here. Agent-editable helpers live in
4
- BH_AGENT_WORKSPACE/agent_helpers.py.
5
- """
6
- import base64, importlib.util, json, math, os, time, urllib.request
7
- from pathlib import Path
8
- from urllib.parse import urlparse
9
-
10
- from . import _ipc as ipc
11
- from . import paths
12
-
13
-
14
- CORE_DIR = Path(__file__).resolve().parent
15
- REPO_ROOT = CORE_DIR.parent.parent
16
- AGENT_WORKSPACE = paths.workspace_dir()
17
-
18
-
19
- def _load_env():
20
- paths = [REPO_ROOT / ".env", AGENT_WORKSPACE / ".env"]
21
- for p in paths:
22
- if not p.exists():
23
- continue
24
- _load_env_file(p)
25
-
26
-
27
- def _load_env_file(p):
28
- for line in p.read_text(encoding="utf-8-sig", errors="replace").splitlines():
29
- line = line.strip()
30
- if not line or line.startswith("#") or "=" not in line:
31
- continue
32
- k, v = line.split("=", 1)
33
- os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
34
-
35
-
36
- _load_env()
37
-
38
- NAME = os.environ.get("BU_NAME", "default")
39
- SOCK = ipc.sock_addr(NAME)
40
- INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:")
41
- IPC_CONNECT_TIMEOUT_SECONDS = 5.0
42
- DEFAULT_IPC_RESPONSE_TIMEOUT_SECONDS = 5.0
43
- # Cloud screenshots routinely take longer than ordinary CDP round trips. Keep
44
- # their IPC socket alive within the caller's existing 90-second process budget.
45
- SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS = 60.0
46
-
47
-
48
- class _IPCResponseTimeout(TimeoutError):
49
- pass
50
-
51
-
52
- def _send(req, response_timeout=DEFAULT_IPC_RESPONSE_TIMEOUT_SECONDS):
53
- c, token = ipc.connect(NAME, timeout=IPC_CONNECT_TIMEOUT_SECONDS)
54
- try:
55
- c.settimeout(response_timeout)
56
- try:
57
- r = ipc.request(c, token, req)
58
- except TimeoutError as e:
59
- # Carry the detail on the exception itself. Raising the bare class
60
- # left str(exc) empty, so every caller that reported the error had
61
- # to rebuild the context by hand or print nothing useful.
62
- label = req.get("method") or req.get("meta") or "request"
63
- raise _IPCResponseTimeout(
64
- f"{label} timed out after {response_timeout:g}s waiting for the daemon"
65
- ) from e
66
- finally:
67
- c.close()
68
- if "error" in r: raise RuntimeError(r["error"])
69
- return r
70
-
71
-
72
- def cdp(method, session_id=None, _response_timeout=DEFAULT_IPC_RESPONSE_TIMEOUT_SECONDS, **params):
73
- """Raw CDP. cdp('Page.navigate', url='...'), cdp('DOM.getDocument', depth=-1)."""
74
- return _send(
75
- {"method": method, "params": params, "session_id": session_id},
76
- response_timeout=_response_timeout,
77
- ).get("result", {})
78
-
79
-
80
- def drain_events(): return _send({"meta": "drain_events"})["events"]
81
-
82
-
83
- def _js_snippet(expression, limit=160):
84
- snippet = expression.strip().replace("\n", "\\n")
85
- return snippet[:limit - 3] + "..." if len(snippet) > limit else snippet
86
-
87
-
88
- def _js_exception_description(result, details):
89
- desc = result.get("description")
90
- exc = details.get("exception") if details else None
91
- if not desc and isinstance(exc, dict):
92
- desc = exc.get("description")
93
- if desc is None and "value" in exc:
94
- desc = str(exc["value"])
95
- if desc is None:
96
- desc = exc.get("className")
97
- if not desc and details:
98
- desc = details.get("text")
99
- return desc or "JavaScript evaluation failed"
100
-
101
-
102
- def _decode_unserializable_js_value(value):
103
- if value == "NaN":
104
- return math.nan
105
- if value == "Infinity":
106
- return math.inf
107
- if value == "-Infinity":
108
- return -math.inf
109
- if value == "-0":
110
- return -0.0
111
- if value.endswith("n"):
112
- return int(value[:-1])
113
- return value
114
-
115
-
116
- def _runtime_value(response, expression):
117
- result = response.get("result", {})
118
- details = response.get("exceptionDetails")
119
- if details or result.get("subtype") == "error":
120
- desc = _js_exception_description(result, details)
121
- if details:
122
- line = details.get("lineNumber")
123
- col = details.get("columnNumber")
124
- loc = f" at line {line}, column {col}" if line is not None and col is not None else ""
125
- else:
126
- loc = ""
127
- raise RuntimeError(f"JavaScript evaluation failed{loc}: {desc}; expression: {_js_snippet(expression)}")
128
- if "value" in result:
129
- return result["value"]
130
- if "unserializableValue" in result:
131
- return _decode_unserializable_js_value(result["unserializableValue"])
132
- return None
133
-
134
-
135
- def _runtime_evaluate(expression, session_id=None, await_promise=False):
136
- try:
137
- r = cdp("Runtime.evaluate", session_id=session_id, expression=expression, returnByValue=True, awaitPromise=await_promise)
138
- except TimeoutError as e:
139
- raise RuntimeError(f"Runtime.evaluate timed out; expression: {_js_snippet(expression)}") from e
140
- return _runtime_value(r, expression)
141
-
142
-
143
- def _wrap_js_function(expression):
144
- return f"(function(){{{expression}}})()"
145
-
146
-
147
- def _is_illegal_return_error(exc):
148
- return "Illegal return statement" in str(exc)
149
-
150
-
151
- # --- navigation / page ---
152
- def goto_url(url):
153
- r = cdp("Page.navigate", url=url)
154
- if os.environ.get("BH_DOMAIN_SKILLS") != "1":
155
- return r
156
- d = (AGENT_WORKSPACE / "domain-skills" / (urlparse(url).hostname or "").removeprefix("www.").split(".")[0])
157
- return {**r, "domain_skills": sorted(p.name for p in d.rglob("*.md"))[:10]} if d.is_dir() else r
158
-
159
- def page_info():
160
- """{url, title, w, h, sx, sy, pw, ph} — viewport + scroll + page size.
161
-
162
- If a native dialog (alert/confirm/prompt/beforeunload) is open, returns
163
- {dialog: {type, message, ...}} instead — the page's JS thread is frozen
164
- until the dialog is handled (see interaction-skills/dialogs.md)."""
165
- dialog = _send({"meta": "pending_dialog"}).get("dialog")
166
- if dialog:
167
- return {"dialog": dialog}
168
- expression = "JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})"
169
- return json.loads(_runtime_evaluate(expression))
170
-
171
- # --- input ---
172
- _debug_click_counter = 0
173
-
174
- def click_at_xy(x, y, button="left", clicks=1):
175
- if os.environ.get("BH_DEBUG_CLICKS"):
176
- global _debug_click_counter
177
- try:
178
- from PIL import Image, ImageDraw
179
- dpr = js("window.devicePixelRatio") or 1
180
- path = capture_screenshot(str(ipc._TMP / f"debug_click_{_debug_click_counter}.png"))
181
- img = Image.open(path)
182
- draw = ImageDraw.Draw(img)
183
- px, py = int(x * dpr), int(y * dpr)
184
- r = int(15 * dpr)
185
- draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=int(3 * dpr))
186
- draw.line([px - r - int(5 * dpr), py, px + r + int(5 * dpr), py], fill="red", width=int(2 * dpr))
187
- draw.line([px, py - r - int(5 * dpr), px, py + r + int(5 * dpr)], fill="red", width=int(2 * dpr))
188
- img.save(path)
189
- print(f"[debug_click] saved {path} (x={x}, y={y}, dpr={dpr})")
190
- except Exception as e:
191
- print(f"[debug_click] overlay failed: {e}")
192
- _debug_click_counter += 1
193
- cdp("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button=button, clickCount=clicks)
194
- cdp("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button=button, clickCount=clicks)
195
-
196
- def type_text(text):
197
- cdp("Input.insertText", text=text)
198
-
199
- _SELECT_ALL_MODIFIER = None
200
- def _select_all_modifier():
201
- """Select-all modifier by the browser's OS (not this process's): 4=Meta on macOS, else 2=Ctrl."""
202
- global _SELECT_ALL_MODIFIER
203
- if _SELECT_ALL_MODIFIER is None:
204
- ua = cdp("Browser.getVersion").get("userAgent", "")
205
- _SELECT_ALL_MODIFIER = 4 if "Mac OS X" in ua or "Macintosh" in ua else 2
206
- return _SELECT_ALL_MODIFIER
207
-
208
- def fill_input(selector, text, clear_first=True, timeout=0.0):
209
- """Fill a framework-managed input (React controlled, Vue v-model, Ember tracked).
210
-
211
- type_text() uses Input.insertText which bypasses framework event listeners and leaves
212
- submit buttons disabled. This helper focuses the element, clears it, types via real
213
- key events, then fires synthetic input+change events so the framework sees the update.
214
-
215
- Raises RuntimeError if the element is not found. Pass timeout>0 to wait for
216
- late-rendered elements (e.g. after a route change) before typing.
217
- """
218
- if timeout > 0:
219
- if not wait_for_element(selector, timeout=timeout):
220
- raise RuntimeError(f"fill_input: element not found: {selector!r}")
221
- focused = js(
222
- f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
223
- f"if(!e)return false;e.focus();return true;}})()"
224
- )
225
- if not focused:
226
- raise RuntimeError(f"fill_input: element not found: {selector!r}")
227
- if clear_first:
228
- # Dispatch select-all directly — NOT via press_key, which always emits a
229
- # `char` event for single-char keys. With Ctrl/Cmd held, that `char`
230
- # makes Chrome treat the input as a printable "a" instead of firing the
231
- # select-all shortcut, leaving the field uncleared.
232
- mods = _select_all_modifier()
233
- select_all = {"key": "a", "code": "KeyA", "modifiers": mods,
234
- "windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65,
235
- "commands": ["SelectAll"]}
236
- cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all)
237
- cdp("Input.dispatchKeyEvent", type="keyUp",
238
- **{k: v for k, v in select_all.items() if k != "commands"})
239
- press_key("Backspace")
240
- for ch in text:
241
- press_key(ch)
242
- js(
243
- f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
244
- f"if(!e)return;"
245
- f"e.dispatchEvent(new Event('input',{{bubbles:true}}));"
246
- f"e.dispatchEvent(new Event('change',{{bubbles:true}}));}})();"
247
- )
248
-
249
- _KEYS = { # key → (windowsVirtualKeyCode, code, text)
250
- "Enter": (13, "Enter", "\r"), "Tab": (9, "Tab", "\t"), "Backspace": (8, "Backspace", ""),
251
- "Escape": (27, "Escape", ""), "Delete": (46, "Delete", ""), " ": (32, "Space", " "),
252
- "ArrowLeft": (37, "ArrowLeft", ""), "ArrowUp": (38, "ArrowUp", ""),
253
- "ArrowRight": (39, "ArrowRight", ""), "ArrowDown": (40, "ArrowDown", ""),
254
- "Home": (36, "Home", ""), "End": (35, "End", ""),
255
- "PageUp": (33, "PageUp", ""), "PageDown": (34, "PageDown", ""),
256
- }
257
- # US-layout physical keys for printable ASCII punctuation: char → (code, virtual key).
258
- # `code` names the physical key, so it is layout-independent and never the
259
- # character itself; the virtual key code is the Win32 VK_OEM_* value, which is
260
- # unrelated to ord(char) for everything except A-Z and 0-9.
261
- _PUNCTUATION_KEYS = {
262
- "`": ("Backquote", 192), "-": ("Minus", 189), "=": ("Equal", 187),
263
- "[": ("BracketLeft", 219), "]": ("BracketRight", 221), "\\": ("Backslash", 220),
264
- ";": ("Semicolon", 186), "'": ("Quote", 222), ",": ("Comma", 188),
265
- ".": ("Period", 190), "/": ("Slash", 191),
266
- }
267
- # Characters a US layout only produces with Shift held, mapped to the unshifted
268
- # character that shares their physical key.
269
- _SHIFTED_CHARS = {
270
- "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6",
271
- "&": "7", "*": "8", "(": "9", ")": "0", "_": "-", "+": "=",
272
- "{": "[", "}": "]", "|": "\\", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/",
273
- }
274
-
275
-
276
- def _printable_key(char):
277
- """(code, virtual key, needs_shift) for one printable ASCII char on a US layout.
278
-
279
- None when the character has no US physical key — accented letters, CJK,
280
- emoji. Those still insert from the char event's text, and inventing a
281
- keyboard key for them would just be a different wrong answer.
282
- """
283
- unshifted = _SHIFTED_CHARS.get(char, char)
284
- needs_shift = char in _SHIFTED_CHARS or char.isupper()
285
- if unshifted.isascii() and "a" <= unshifted.lower() <= "z":
286
- return f"Key{unshifted.upper()}", ord(unshifted.upper()), needs_shift
287
- if unshifted.isdigit() and unshifted.isascii():
288
- return f"Digit{unshifted}", ord(unshifted), needs_shift
289
- if unshifted in _PUNCTUATION_KEYS:
290
- code, vk = _PUNCTUATION_KEYS[unshifted]
291
- return code, vk, needs_shift
292
- return None
293
-
294
-
295
- def press_key(key, modifiers=0):
296
- """Modifiers bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift.
297
-
298
- Named keys (Enter, Tab, Arrow*, Backspace, ...) and printable characters alike
299
- carry the physical `code` and virtual key code a real US keyboard sends, so
300
- listeners reading e.key, e.code and e.keyCode all agree. A character that
301
- needs Shift on that layout (uppercase, !@#$...) sets the Shift modifier too,
302
- unless the caller is already composing a shortcut with Alt/Ctrl/Meta — there,
303
- the caller's intent wins over the physical truth.
304
- """
305
- if key in _KEYS:
306
- vk, code, text = _KEYS[key]
307
- elif len(key) == 1:
308
- text = key
309
- resolved = _printable_key(key)
310
- if resolved:
311
- code, vk, needs_shift = resolved
312
- if needs_shift and not modifiers & (1 | 2 | 4):
313
- modifiers |= 8
314
- else:
315
- code, vk = "", 0
316
- else:
317
- vk, code, text = 0, key, ""
318
- base = {"key": key, "code": code, "modifiers": modifiers, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk}
319
- shortcut_modifiers = modifiers & (1 | 2 | 4) # Alt/Ctrl/Meta turn single keys into shortcuts.
320
- printable_char = len(key) == 1 and bool(text) and not shortcut_modifiers
321
- cdp("Input.dispatchKeyEvent", type="keyDown", **base, **({} if printable_char or not text else {"text": text}))
322
- if printable_char:
323
- cdp("Input.dispatchKeyEvent", type="char", text=text, **{k: v for k, v in base.items() if k != "text"})
324
- cdp("Input.dispatchKeyEvent", type="keyUp", **base)
325
-
326
- def scroll(x, y, dy=-300, dx=0):
327
- cdp("Input.dispatchMouseEvent", type="mouseWheel", x=x, y=y, deltaX=dx, deltaY=dy)
328
-
329
-
330
- # --- visual ---
331
- def capture_screenshot(path=None, full=False, max_dim=None):
332
- """Save a PNG of the current viewport. Set max_dim=1800 on a 2× display to
333
- keep the file under the 2000px-per-side limit some image-aware LLMs enforce."""
334
- path = path or str(ipc._TMP / "shot.png")
335
- try:
336
- r = cdp(
337
- "Page.captureScreenshot",
338
- _response_timeout=SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS,
339
- format="png",
340
- captureBeyondViewport=full,
341
- )
342
- except _IPCResponseTimeout as e:
343
- raise RuntimeError(
344
- f"Page.captureScreenshot timed out after {SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS:g}s"
345
- ) from e
346
- open(path, "wb").write(base64.b64decode(r["data"]))
347
- if max_dim:
348
- from PIL import Image
349
- img = Image.open(path)
350
- if max(img.size) > max_dim:
351
- img.thumbnail((max_dim, max_dim))
352
- img.save(path)
353
- return path
354
-
355
-
356
- # --- tabs ---
357
- def _is_agent_startup_placeholder(title, url):
358
- url = str(url or "")
359
- return str(title or "").startswith("Starting agent ") and (
360
- url in ("", "about:blank") or url.startswith("about:blank#")
361
- )
362
-
363
-
364
- def list_tabs(include_chrome=True):
365
- out = []
366
- for t in cdp("Target.getTargets")["targetInfos"]:
367
- if t["type"] != "page": continue
368
- url = t.get("url", "")
369
- if _is_agent_startup_placeholder(t.get("title", ""), url): continue
370
- if not include_chrome and url.startswith(INTERNAL): continue
371
- out.append({
372
- "targetId": t["targetId"],
373
- "target_id": t["targetId"],
374
- "title": t.get("title", ""),
375
- "url": url,
376
- })
377
- return out
378
-
379
- def current_tab():
380
- r = _send({"meta": "current_tab"})
381
- return {
382
- "targetId": r["targetId"],
383
- "target_id": r["targetId"],
384
- "url": r["url"],
385
- "title": r["title"],
386
- }
387
-
388
- def _mark_tab():
389
- """Prepend horse emoji to tab title so the user can see which tab the agent controls."""
390
- if os.environ.get("BH_TAB_MARKER", "").strip().lower() in {"0", "false", "no", "off"}:
391
- return
392
- try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title")
393
- except Exception: pass
394
-
395
- def _target_id(target):
396
- """Accept a raw target id or a tab dict returned by the helpers."""
397
- return (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target
398
-
399
- def activate_tab(target):
400
- """Make a target the visible Chrome tab.
401
-
402
- This is intentionally separate from switch_tab(): attaching the agent to a
403
- target does not require taking over the user's visible Chrome tab.
404
- """
405
- target_id = _target_id(target)
406
- cdp("Target.activateTarget", targetId=target_id)
407
- return target_id
408
-
409
- def switch_tab(target, activate=False):
410
- """Attach the agent without changing Chrome's visible tab by default.
411
-
412
- Pass activate=True only when Chrome must visibly show the target. The horse
413
- marker still moves to the attached target so the user can find it.
414
- """
415
- # Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(),
416
- # so `switch_tab(current_tab())` works without a manual ["targetId"] dance.
417
- target_id = _target_id(target)
418
- # Unmark old tab. Horse emoji is a surrogate pair in JS UTF-16 strings (2 code units),
419
- # plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix.
420
- try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)")
421
- except Exception: pass
422
- if activate:
423
- activate_tab(target_id)
424
- sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
425
- _send({"meta": "set_session", "session_id": sid, "target_id": target_id})
426
- _mark_tab()
427
- return sid
428
-
429
- def new_tab(url="about:blank"):
430
- # Always create blank, then goto: passing url to createTarget races with
431
- # attach, so the brief about:blank is "complete" by the time the caller
432
- # polls and wait_for_load() returns before navigation actually starts.
433
- if url != "about:blank":
434
- try:
435
- cur = current_tab()
436
- cur_url = cur.get("url") or ""
437
- # Reuse attached tab when it's blank
438
- if (
439
- cur_url in ("", "about:blank", "data:text/html,")
440
- or cur_url.startswith("about:blank#")
441
- or cur_url.startswith(("chrome://newtab", "chrome://new-tab-page", "edge://newtab", "about:newtab"))
442
- ):
443
- goto_url(url)
444
- return cur.get("targetId") or cur.get("target_id")
445
- except Exception:
446
- pass
447
- tid = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
448
- switch_tab(tid)
449
- if url != "about:blank":
450
- goto_url(url)
451
- return tid
452
-
453
- def close_tab(target=None):
454
- """Close a tab. If `target` is omitted, closes the currently attached tab.
455
- Accepts a raw targetId string or a dict from list_tabs()/current_tab()."""
456
- target_id = _target_id(target)
457
- if target_id is None:
458
- target_id = current_tab()["targetId"]
459
- cdp("Target.closeTarget", targetId=target_id)
460
-
461
-
462
- def ensure_real_tab():
463
- """Switch to a real user tab if current is chrome:// / internal / stale."""
464
- tabs = list_tabs(include_chrome=False)
465
- if not tabs:
466
- return None
467
- try:
468
- cur = current_tab()
469
- if cur["url"] and not cur["url"].startswith(INTERNAL):
470
- return cur
471
- except Exception:
472
- pass
473
- switch_tab(tabs[0]["targetId"])
474
- return tabs[0]
475
-
476
- def iframe_target(url_substr):
477
- """First iframe target whose URL contains `url_substr`. Use with js(..., target_id=...)."""
478
- for t in cdp("Target.getTargets")["targetInfos"]:
479
- if t["type"] == "iframe" and url_substr in t.get("url", ""):
480
- return t["targetId"]
481
- return None
482
-
483
-
484
- # --- utility ---
485
- def wait(seconds=1.0):
486
- time.sleep(seconds)
487
-
488
- def wait_for_load(timeout=15.0):
489
- """Poll document.readyState == 'complete' or timeout."""
490
- deadline = time.time() + timeout
491
- while time.time() < deadline:
492
- if js("document.readyState") == "complete": return True
493
- time.sleep(0.3)
494
- return False
495
-
496
- def wait_for_element(selector, timeout=10.0, visible=False):
497
- """Poll until querySelector(selector) exists in the DOM, or timeout.
498
-
499
- wait_for_load() misses SPAs — the document is 'complete' before the framework renders.
500
- Use this after actions that trigger async rendering (route changes, data fetches).
501
- Set visible=True to also require the element to be non-hidden and in-layout.
502
- Returns True if found, False on timeout.
503
- """
504
- if visible:
505
- # checkVisibility walks the ancestor chain and respects display:none /
506
- # visibility:hidden / opacity:0 on parents, which a getComputedStyle
507
- # check on the element alone misses (it returns the descendant's own
508
- # style, not the inherited "is this rendered" state). Falls back to
509
- # the per-element CSS check on older Chrome that lacks checkVisibility.
510
- check = (
511
- f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
512
- f"if(!e)return false;"
513
- f"if(typeof e.checkVisibility==='function')"
514
- f"return e.checkVisibility({{checkOpacity:true,checkVisibilityCSS:true}});"
515
- f"const s=getComputedStyle(e);"
516
- f"return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'}})()"
517
- )
518
- else:
519
- check = f"!!document.querySelector({json.dumps(selector)})"
520
- deadline = time.time() + timeout
521
- while time.time() < deadline:
522
- if js(check): return True
523
- time.sleep(0.3)
524
- return False
525
-
526
- def wait_for_network_idle(timeout=10.0, idle_ms=500):
527
- """Wait until all in-flight requests finish and no Network.* events arrive for idle_ms ms.
528
-
529
- Useful after form submits, SPA route transitions, and any action that triggers
530
- XHR/fetch without a visible DOM change. Builds on drain_events() — no daemon changes.
531
- Returns True if idle window reached, False on timeout.
532
-
533
- Events are filtered to the active session — a previously-attached background
534
- tab (e.g. a polling/SSE page the agent switched away from) keeps emitting
535
- Network events into the daemon's global event buffer; without this filter
536
- they would poison the idle check on the current tab.
537
- """
538
- deadline = time.time() + timeout
539
- last_activity = time.time()
540
- inflight = set()
541
- active_session = _send({"meta": "session"}).get("session_id")
542
- while time.time() < deadline:
543
- for e in drain_events():
544
- if e.get("session_id") != active_session:
545
- continue
546
- method = e.get("method", "")
547
- params = e.get("params", {})
548
- if method == "Network.requestWillBeSent":
549
- inflight.add(params.get("requestId"))
550
- last_activity = time.time()
551
- elif method in ("Network.loadingFinished", "Network.loadingFailed"):
552
- inflight.discard(params.get("requestId"))
553
- last_activity = time.time()
554
- elif method.startswith("Network."):
555
- last_activity = time.time()
556
- if not inflight and (time.time() - last_activity) * 1000 >= idle_ms:
557
- return True
558
- time.sleep(0.1)
559
- return False
560
-
561
- def js(expression, target_id=None):
562
- """Run JS in the attached tab (default) or inside an iframe target (via iframe_target()).
563
-
564
- Expressions are evaluated as-is first. If Chrome reports an illegal top-level
565
- `return`, the snippet is retried inside a function wrapper, so both
566
- `document.title` and `const x = 1; return x` work without mis-wrapping nested
567
- functions that contain their own returns.
568
- """
569
- sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] if target_id else None
570
- try:
571
- result = _js_evaluate(expression, sid)
572
- except BaseException:
573
- # Keep the evaluation error; a detach failure here must not replace it.
574
- if sid:
575
- try:
576
- _detach_iframe_session(sid)
577
- except BaseException:
578
- pass
579
- raise
580
- if sid:
581
- _detach_iframe_session(sid)
582
- return result
583
-
584
-
585
- def _js_evaluate(expression, sid):
586
- try:
587
- return _runtime_evaluate(expression, session_id=sid, await_promise=True)
588
- except RuntimeError as e:
589
- if _is_illegal_return_error(e):
590
- return _runtime_evaluate(_wrap_js_function(expression), session_id=sid, await_promise=True)
591
- raise
592
-
593
-
594
- def _detach_iframe_session(sid):
595
- """Release the session js(target_id=...) attached so polling loops do not
596
- accumulate one live session (and one event stream) per call. A session that
597
- Chrome already dropped (iframe navigated or closed) is not a leak."""
598
- try:
599
- cdp("Target.detachFromTarget", sessionId=sid)
600
- except Exception as e:
601
- message = str(e).lower()
602
- if "no session with given id" in message or "session with given id not found" in message:
603
- return
604
- raise
605
-
606
-
607
- _KC = {"Enter": 13, "Tab": 9, "Escape": 27, "Backspace": 8, " ": 32, "ArrowLeft": 37, "ArrowUp": 38, "ArrowRight": 39, "ArrowDown": 40}
608
-
609
-
610
- def dispatch_key(selector, key="Enter", event="keypress"):
611
- """Dispatch a DOM KeyboardEvent on the matched element.
612
-
613
- Use this when a site reacts to synthetic DOM key events on an element more reliably
614
- than to raw CDP input events.
615
- """
616
- kc = _KC.get(key, ord(key) if len(key) == 1 else 0)
617
- js(
618
- f"(()=>{{const e=document.querySelector({json.dumps(selector)});if(e){{e.focus();e.dispatchEvent(new KeyboardEvent({json.dumps(event)},{{key:{json.dumps(key)},code:{json.dumps(key)},keyCode:{kc},which:{kc},bubbles:true}}));}}}})()"
619
- )
620
-
621
- def upload_file(selector, path):
622
- """Set files on a file input via CDP DOM.setFileInputFiles. `path` is an absolute filepath (use tempfile.mkstemp if needed)."""
623
- doc = cdp("DOM.getDocument", depth=-1)
624
- nid = cdp("DOM.querySelector", nodeId=doc["root"]["nodeId"], selector=selector)["nodeId"]
625
- if not nid: raise RuntimeError(f"no element for {selector}")
626
- cdp("DOM.setFileInputFiles", files=[path] if isinstance(path, str) else list(path), nodeId=nid)
627
-
628
- def http_get(url, headers=None, timeout=20.0):
629
- """Pure HTTP — no browser. Use for static pages / APIs. Wrap in ThreadPoolExecutor for bulk.
630
-
631
- When BROWSER_USE_API_KEY is set, routes through the fetch-use proxy (handles bot
632
- detection, residential proxies, retries). Falls back to local urllib otherwise."""
633
- if os.environ.get("BROWSER_USE_API_KEY"):
634
- try:
635
- from fetch_use import fetch_sync
636
- return fetch_sync(url, headers=headers, timeout_ms=int(timeout * 1000)).text
637
- except ImportError:
638
- pass
639
- import gzip
640
- h = {"User-Agent": "Mozilla/5.0", "Accept-Encoding": "gzip"}
641
- if headers: h.update(headers)
642
- with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout) as r:
643
- data = r.read()
644
- if r.headers.get("Content-Encoding") == "gzip": data = gzip.decompress(data)
645
- return data.decode()
646
-
647
-
648
- # Imported at the bottom so recorder's own `from . import helpers` sees a
649
- # fully-defined module. Exposes the recording helpers via `from .helpers import *`.
650
- from .recorder import start_recording, stop_recording, recording_dir
651
-
652
-
653
- def _load_agent_helpers():
654
- p = AGENT_WORKSPACE / "agent_helpers.py"
655
- if not p.exists():
656
- return
657
- spec = importlib.util.spec_from_file_location("browser_harness_agent_helpers", p)
658
- if not spec or not spec.loader:
659
- return
660
- module = importlib.util.module_from_spec(spec)
661
- spec.loader.exec_module(module)
662
- for name, value in vars(module).items():
663
- if name.startswith("_"):
664
- continue
665
- globals()[name] = value
666
-
667
-
668
- _load_agent_helpers()