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,718 @@
|
|
|
1
|
+
"""Restrict Browser Harness tab operations to one named Browser Workspace.
|
|
2
|
+
|
|
3
|
+
Any function that rewrites a ``browser_harness.helpers`` function must keep the
|
|
4
|
+
upstream function's input signature exactly. Browser Workspace may change
|
|
5
|
+
behavior, but it must remain a drop-in helper layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextvars as _contextvars
|
|
9
|
+
import json as _json
|
|
10
|
+
import os as _os
|
|
11
|
+
import time as _time
|
|
12
|
+
|
|
13
|
+
from browser_harness import helpers as _bh
|
|
14
|
+
|
|
15
|
+
_EXTENSION_ID = _os.environ.get(
|
|
16
|
+
"BH_WORKSPACE_MANAGER_EXTENSION_ID",
|
|
17
|
+
"kgbghhigmbpefppgkocgjgnnnbhjchic",
|
|
18
|
+
)
|
|
19
|
+
_DEFAULT_WORKSPACE_NAME = _os.environ.get(
|
|
20
|
+
"BH_WORKSPACE_NAME",
|
|
21
|
+
_os.environ.get("BH_MDB_GROUP_NAME", "Harness"),
|
|
22
|
+
)
|
|
23
|
+
_WORKSPACE_NAME = _DEFAULT_WORKSPACE_NAME
|
|
24
|
+
_WORKSPACE_CONTEXT = _contextvars.ContextVar("browser_workspace_name", default=None)
|
|
25
|
+
|
|
26
|
+
def workspace_name():
|
|
27
|
+
return _WORKSPACE_CONTEXT.get() or _WORKSPACE_NAME
|
|
28
|
+
|
|
29
|
+
def workspace_set_name(name):
|
|
30
|
+
if not isinstance(name, str) or not name.strip():
|
|
31
|
+
raise ValueError("workspace name must be a non-empty string")
|
|
32
|
+
return _WORKSPACE_CONTEXT.set(name.strip())
|
|
33
|
+
|
|
34
|
+
def workspace_reset_name(token):
|
|
35
|
+
_WORKSPACE_CONTEXT.reset(token)
|
|
36
|
+
_POOL_SIZE = int(_os.environ.get("BH_WORKSPACE_POOL_SIZE", "5"))
|
|
37
|
+
_TIMEOUT_SECONDS = 5.0
|
|
38
|
+
_EXTENSION_WEBSTORE_URL = f"https://chromewebstore.google.com/detail/{_EXTENSION_ID}"
|
|
39
|
+
_WORKSPACE_SUPPORTED_CONTEXT = _contextvars.ContextVar("browser_workspace_supported", default=None)
|
|
40
|
+
|
|
41
|
+
_original_switch_tab = _bh.switch_tab
|
|
42
|
+
_original_current_tab = _bh.current_tab
|
|
43
|
+
_original_goto_url = _bh.goto_url
|
|
44
|
+
_original_list_tabs = _bh.list_tabs
|
|
45
|
+
_original_new_tab = _bh.new_tab
|
|
46
|
+
_original_close_tab = _bh.close_tab
|
|
47
|
+
_original_activate_tab = _bh.activate_tab
|
|
48
|
+
|
|
49
|
+
_tab_to_target = {}
|
|
50
|
+
_target_to_tab = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def workspace_set_supported(value):
|
|
55
|
+
return _WORKSPACE_SUPPORTED_CONTEXT.set(bool(value))
|
|
56
|
+
|
|
57
|
+
def workspace_reset_supported(token):
|
|
58
|
+
_WORKSPACE_SUPPORTED_CONTEXT.reset(token)
|
|
59
|
+
|
|
60
|
+
def workspace_extension_status():
|
|
61
|
+
try:
|
|
62
|
+
_worker_target()
|
|
63
|
+
return {
|
|
64
|
+
"supported": True,
|
|
65
|
+
"extensionId": _EXTENSION_ID,
|
|
66
|
+
"extensionUrl": _EXTENSION_WEBSTORE_URL,
|
|
67
|
+
}
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
return {
|
|
70
|
+
"supported": False,
|
|
71
|
+
"extensionId": _EXTENSION_ID,
|
|
72
|
+
"extensionUrl": _EXTENSION_WEBSTORE_URL,
|
|
73
|
+
"reason": str(exc),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def workspace_supported():
|
|
77
|
+
scoped = _WORKSPACE_SUPPORTED_CONTEXT.get()
|
|
78
|
+
if scoped is not None:
|
|
79
|
+
return bool(scoped)
|
|
80
|
+
return bool(workspace_extension_status()["supported"])
|
|
81
|
+
|
|
82
|
+
def _require_workspace_support():
|
|
83
|
+
if workspace_supported():
|
|
84
|
+
return
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"Browser Workspace extension is not installed; workspace grouping/leases are unavailable. "
|
|
87
|
+
f"Install it from {_EXTENSION_WEBSTORE_URL}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _worker_target():
|
|
91
|
+
scope = f"chrome-extension://{_EXTENSION_ID}/"
|
|
92
|
+
try:
|
|
93
|
+
_bh.cdp("ServiceWorker.enable")
|
|
94
|
+
_bh.cdp("ServiceWorker.startWorker", scopeURL=scope)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Browser Workspace extension {_EXTENSION_ID} is not installed"
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
deadline = _time.monotonic() + _TIMEOUT_SECONDS
|
|
101
|
+
while _time.monotonic() < deadline:
|
|
102
|
+
for target in _bh.cdp("Target.getTargets").get("targetInfos", []):
|
|
103
|
+
if target.get("type") != "service_worker" or not target.get("url", "").startswith(scope):
|
|
104
|
+
continue
|
|
105
|
+
target_id = target["targetId"]
|
|
106
|
+
try:
|
|
107
|
+
if _bh.js(
|
|
108
|
+
"typeof globalThis.browserWorkspaceManagerRpc === 'function'",
|
|
109
|
+
target_id=target_id,
|
|
110
|
+
):
|
|
111
|
+
return target_id
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
_time.sleep(0.05)
|
|
115
|
+
raise RuntimeError("Browser Workspace service worker did not become ready")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _manager_call(method, args=None):
|
|
119
|
+
_require_workspace_support()
|
|
120
|
+
request = _json.dumps({"method": method, "args": args or {}})
|
|
121
|
+
response = _bh.js(
|
|
122
|
+
f"globalThis.browserWorkspaceManagerRpc({request})",
|
|
123
|
+
target_id=_worker_target(),
|
|
124
|
+
)
|
|
125
|
+
if not isinstance(response, dict) or not response.get("ok"):
|
|
126
|
+
error = response.get("error", {}) if isinstance(response, dict) else {}
|
|
127
|
+
raise RuntimeError(error.get("message") or "Browser Workspace request failed")
|
|
128
|
+
return response.get("result") or {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def workspace_create(name, pool_size=5):
|
|
132
|
+
return _manager_call(
|
|
133
|
+
"workspace.create",
|
|
134
|
+
{"name": name, "poolSize": int(pool_size)},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def workspace_list():
|
|
139
|
+
return _manager_call("workspace.list")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def workspace_resize(name, pool_size):
|
|
143
|
+
return _manager_call(
|
|
144
|
+
"workspace.resize",
|
|
145
|
+
{"name": name, "poolSize": int(pool_size)},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def workspace_delete(name, force=False):
|
|
150
|
+
return _manager_call(
|
|
151
|
+
"workspace.delete",
|
|
152
|
+
{"name": name, "force": bool(force)},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ensure_workspace():
|
|
157
|
+
return _manager_call(
|
|
158
|
+
"workspace.ensure",
|
|
159
|
+
{"name": workspace_name(), "poolSize": _POOL_SIZE},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def workspace_status():
|
|
164
|
+
if not workspace_supported():
|
|
165
|
+
return {
|
|
166
|
+
"name": workspace_name(),
|
|
167
|
+
"supported": False,
|
|
168
|
+
"extensionUrl": _EXTENSION_WEBSTORE_URL,
|
|
169
|
+
"initialized": False,
|
|
170
|
+
}
|
|
171
|
+
status = _ensure_workspace()
|
|
172
|
+
status["supported"] = True
|
|
173
|
+
status["extensionUrl"] = _EXTENSION_WEBSTORE_URL
|
|
174
|
+
return status
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def workspace_capacity():
|
|
178
|
+
"""Return routing-friendly capacity facts derived from workspace_status()."""
|
|
179
|
+
try:
|
|
180
|
+
status = workspace_status()
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
return {
|
|
183
|
+
"name": workspace_name(),
|
|
184
|
+
"poolSize": None,
|
|
185
|
+
"maxCapacity": None,
|
|
186
|
+
"physicalTabs": None,
|
|
187
|
+
"idle": None,
|
|
188
|
+
"leased": None,
|
|
189
|
+
"available": None,
|
|
190
|
+
"error": str(exc),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
idle_ids = status.get("idleTabIds")
|
|
194
|
+
leased_ids = status.get("leasedTabIds")
|
|
195
|
+
if not status.get("initialized") or not isinstance(idle_ids, list) or not isinstance(leased_ids, list):
|
|
196
|
+
return {
|
|
197
|
+
"name": status.get("name", workspace_name()),
|
|
198
|
+
"poolSize": status.get("poolSize"),
|
|
199
|
+
"maxCapacity": status.get("maxCapacity", status.get("poolSize")),
|
|
200
|
+
"physicalTabs": None,
|
|
201
|
+
"idle": None,
|
|
202
|
+
"leased": None,
|
|
203
|
+
"available": None,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
idle = len(idle_ids)
|
|
207
|
+
leased = len(leased_ids)
|
|
208
|
+
max_capacity = status.get("maxCapacity", status.get("poolSize"))
|
|
209
|
+
physical_tabs = status.get("physicalTabs")
|
|
210
|
+
if physical_tabs is None:
|
|
211
|
+
physical_tabs = idle + leased
|
|
212
|
+
available = max(0, max_capacity - leased) if isinstance(max_capacity, int) else None
|
|
213
|
+
return {
|
|
214
|
+
"name": status.get("name", workspace_name()),
|
|
215
|
+
"poolSize": status.get("poolSize"),
|
|
216
|
+
"maxCapacity": max_capacity,
|
|
217
|
+
"physicalTabs": physical_tabs,
|
|
218
|
+
"idle": idle,
|
|
219
|
+
"leased": leased,
|
|
220
|
+
"available": available,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _target_id(target):
|
|
225
|
+
if isinstance(target, dict):
|
|
226
|
+
return target.get("targetId") or target.get("target_id")
|
|
227
|
+
return target
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _page_targets():
|
|
231
|
+
return [
|
|
232
|
+
target
|
|
233
|
+
for target in _bh.cdp("Target.getTargets").get("targetInfos", [])
|
|
234
|
+
if target.get("type") == "page"
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _remember_mapping(tab_id, target_id):
|
|
239
|
+
old_target = _tab_to_target.get(tab_id)
|
|
240
|
+
if old_target and old_target != target_id:
|
|
241
|
+
_target_to_tab.pop(old_target, None)
|
|
242
|
+
old_tab = _target_to_tab.get(target_id)
|
|
243
|
+
if old_tab is not None and old_tab != tab_id:
|
|
244
|
+
_tab_to_target.pop(old_tab, None)
|
|
245
|
+
_tab_to_target[tab_id] = target_id
|
|
246
|
+
_target_to_tab[target_id] = tab_id
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _forget_mapping(tab_id=None, target_id=None):
|
|
250
|
+
if tab_id is not None:
|
|
251
|
+
known_target = _tab_to_target.pop(tab_id, None)
|
|
252
|
+
if known_target:
|
|
253
|
+
_target_to_tab.pop(known_target, None)
|
|
254
|
+
if target_id is not None:
|
|
255
|
+
known_tab = _target_to_tab.pop(target_id, None)
|
|
256
|
+
if known_tab is not None:
|
|
257
|
+
_tab_to_target.pop(known_tab, None)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _map_workspace_tabs(chrome_tabs, target_infos):
|
|
262
|
+
"""Map Chrome tabs to CDP targets, preferring mappings learned at acquire."""
|
|
263
|
+
remaining = {
|
|
264
|
+
target.get("targetId"): target
|
|
265
|
+
for target in target_infos
|
|
266
|
+
if target.get("targetId")
|
|
267
|
+
}
|
|
268
|
+
mapped = []
|
|
269
|
+
|
|
270
|
+
for tab in chrome_tabs:
|
|
271
|
+
tab_id = tab.get("tabId")
|
|
272
|
+
target_id = _tab_to_target.get(tab_id)
|
|
273
|
+
target = remaining.get(target_id)
|
|
274
|
+
if target is None:
|
|
275
|
+
if target_id:
|
|
276
|
+
_forget_mapping(tab_id=tab_id)
|
|
277
|
+
continue
|
|
278
|
+
remaining.pop(target_id, None)
|
|
279
|
+
mapped.append(
|
|
280
|
+
{
|
|
281
|
+
"targetId": target_id,
|
|
282
|
+
"target_id": target_id,
|
|
283
|
+
"tabId": tab_id,
|
|
284
|
+
"groupId": tab.get("groupId"),
|
|
285
|
+
"title": target.get("title", ""),
|
|
286
|
+
"url": target.get("url", ""),
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
mapped_tab_ids = {tab["tabId"] for tab in mapped}
|
|
291
|
+
for tab in chrome_tabs:
|
|
292
|
+
if tab.get("tabId") in mapped_tab_ids:
|
|
293
|
+
continue
|
|
294
|
+
url = tab.get("url") or ""
|
|
295
|
+
candidates = [
|
|
296
|
+
target
|
|
297
|
+
for target in remaining.values()
|
|
298
|
+
if (target.get("url") or "") == url
|
|
299
|
+
]
|
|
300
|
+
if len(candidates) > 1:
|
|
301
|
+
title = tab.get("title") or ""
|
|
302
|
+
candidates = [
|
|
303
|
+
target
|
|
304
|
+
for target in candidates
|
|
305
|
+
if (target.get("title") or "") == title
|
|
306
|
+
]
|
|
307
|
+
if len(candidates) != 1:
|
|
308
|
+
continue
|
|
309
|
+
target = candidates[0]
|
|
310
|
+
target_id = target["targetId"]
|
|
311
|
+
remaining.pop(target_id, None)
|
|
312
|
+
_remember_mapping(tab.get("tabId"), target_id)
|
|
313
|
+
mapped.append(
|
|
314
|
+
{
|
|
315
|
+
"targetId": target_id,
|
|
316
|
+
"target_id": target_id,
|
|
317
|
+
"tabId": tab.get("tabId"),
|
|
318
|
+
"groupId": tab.get("groupId"),
|
|
319
|
+
"title": target.get("title", ""),
|
|
320
|
+
"url": target.get("url", ""),
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
return mapped
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _workspace_tabs():
|
|
327
|
+
status = workspace_status()
|
|
328
|
+
return _map_workspace_tabs(status.get("tabs", []), _page_targets())
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _workspace_tab_for_target(target):
|
|
332
|
+
wanted = _target_id(target)
|
|
333
|
+
matches = [tab for tab in _workspace_tabs() if tab["targetId"] == wanted]
|
|
334
|
+
if len(matches) != 1:
|
|
335
|
+
raise RuntimeError(
|
|
336
|
+
f"Refusing Browser Harness access outside workspace {workspace_name()!r}"
|
|
337
|
+
)
|
|
338
|
+
return matches[0]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def list_tabs(include_chrome=True):
|
|
342
|
+
if not workspace_supported():
|
|
343
|
+
return _original_list_tabs(include_chrome=include_chrome)
|
|
344
|
+
tabs = _workspace_tabs()
|
|
345
|
+
if include_chrome:
|
|
346
|
+
return tabs
|
|
347
|
+
internal = (
|
|
348
|
+
"chrome://",
|
|
349
|
+
"chrome-untrusted://",
|
|
350
|
+
"devtools://",
|
|
351
|
+
"chrome-extension://",
|
|
352
|
+
"about:",
|
|
353
|
+
)
|
|
354
|
+
return [tab for tab in tabs if not tab["url"].startswith(internal)]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def current_tab():
|
|
358
|
+
if not workspace_supported():
|
|
359
|
+
return _original_current_tab()
|
|
360
|
+
return _workspace_tab_for_target(_original_current_tab())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def activate_tab(target):
|
|
364
|
+
if not workspace_supported():
|
|
365
|
+
return _original_activate_tab(target)
|
|
366
|
+
raise RuntimeError("Workspace mode refuses visible tab activation")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def switch_tab(target, activate=False):
|
|
370
|
+
if not workspace_supported():
|
|
371
|
+
return _original_switch_tab(target, activate=activate)
|
|
372
|
+
if activate:
|
|
373
|
+
raise RuntimeError("Workspace mode refuses visible tab activation")
|
|
374
|
+
tab = _workspace_tab_for_target(target)
|
|
375
|
+
return _original_switch_tab(tab["targetId"], activate=False)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def new_tab(url="about:blank"):
|
|
379
|
+
if not workspace_supported():
|
|
380
|
+
return _original_new_tab(url)
|
|
381
|
+
if not (
|
|
382
|
+
url == "about:blank"
|
|
383
|
+
or url.startswith("http://")
|
|
384
|
+
or url.startswith("https://")
|
|
385
|
+
or url.startswith("chrome-extension://")
|
|
386
|
+
):
|
|
387
|
+
raise RuntimeError(
|
|
388
|
+
"Workspace new_tab requires an http(s) or chrome-extension URL"
|
|
389
|
+
)
|
|
390
|
+
opened = _manager_call(
|
|
391
|
+
"workspace.acquireIdentity",
|
|
392
|
+
{"name": workspace_name()},
|
|
393
|
+
)
|
|
394
|
+
wanted_tab_id = opened.get("tabId")
|
|
395
|
+
identity_title = opened.get("identityTitle") or f"__BW_TAB_{wanted_tab_id}__"
|
|
396
|
+
identity_url = opened.get("identityUrl") or ""
|
|
397
|
+
deadline = _time.monotonic() + _TIMEOUT_SECONDS
|
|
398
|
+
while _time.monotonic() < deadline:
|
|
399
|
+
targets = _page_targets()
|
|
400
|
+
title_candidates = [
|
|
401
|
+
target
|
|
402
|
+
for target in targets
|
|
403
|
+
if (target.get("title") or "").removeprefix("🐴 ") == identity_title
|
|
404
|
+
]
|
|
405
|
+
candidates = title_candidates
|
|
406
|
+
if not candidates and identity_url:
|
|
407
|
+
candidates = [
|
|
408
|
+
target
|
|
409
|
+
for target in targets
|
|
410
|
+
if (target.get("url") or "") == identity_url
|
|
411
|
+
]
|
|
412
|
+
if len(candidates) == 1:
|
|
413
|
+
target_id = candidates[0]["targetId"]
|
|
414
|
+
_remember_mapping(wanted_tab_id, target_id)
|
|
415
|
+
_original_switch_tab(target_id, activate=False)
|
|
416
|
+
_original_goto_url(url)
|
|
417
|
+
return target_id
|
|
418
|
+
_time.sleep(0.05)
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
_manager_call(
|
|
422
|
+
"workspace.release",
|
|
423
|
+
{"name": workspace_name(), "tabId": wanted_tab_id},
|
|
424
|
+
)
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
raise RuntimeError("Workspace tab could not be uniquely mapped to a CDP target")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def close_tab(target=None):
|
|
431
|
+
if not workspace_supported():
|
|
432
|
+
return _original_close_tab(target)
|
|
433
|
+
tab = current_tab() if target is None else _workspace_tab_for_target(target)
|
|
434
|
+
result = _manager_call(
|
|
435
|
+
"workspace.release",
|
|
436
|
+
{"name": workspace_name(), "tabId": tab["tabId"]},
|
|
437
|
+
)
|
|
438
|
+
_forget_mapping(tab_id=tab["tabId"])
|
|
439
|
+
return result
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def ensure_real_tab():
|
|
443
|
+
try:
|
|
444
|
+
current = current_tab()
|
|
445
|
+
if current["url"] and not current["url"].startswith(
|
|
446
|
+
(
|
|
447
|
+
"chrome://",
|
|
448
|
+
"chrome-untrusted://",
|
|
449
|
+
"devtools://",
|
|
450
|
+
"chrome-extension://",
|
|
451
|
+
"about:",
|
|
452
|
+
)
|
|
453
|
+
):
|
|
454
|
+
return current
|
|
455
|
+
except RuntimeError:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
tabs = list_tabs(include_chrome=False)
|
|
459
|
+
if not tabs:
|
|
460
|
+
return None
|
|
461
|
+
switch_tab(tabs[0])
|
|
462
|
+
return tabs[0]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def goto_url(url):
|
|
466
|
+
if workspace_supported():
|
|
467
|
+
current_tab()
|
|
468
|
+
return _original_goto_url(url)
|
|
469
|
+
|
|
470
|
+
# Snapshot role selection is adapted from vercel-labs/agent-browser's
|
|
471
|
+
# accessibility snapshot design (Apache-2.0), while Browser Workspace keeps a
|
|
472
|
+
# deliberately simpler ref contract: refs are written to the live DOM as
|
|
473
|
+
# data-ref attributes instead of stored in a separate ref registry.
|
|
474
|
+
_SNAPSHOT_INTERACTIVE_ROLES = {
|
|
475
|
+
"button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
|
|
476
|
+
"menuitem", "menuitemcheckbox", "menuitemradio", "option", "searchbox",
|
|
477
|
+
"slider", "spinbutton", "switch", "tab", "treeitem",
|
|
478
|
+
}
|
|
479
|
+
_SNAPSHOT_CONTENT_ROLES = {
|
|
480
|
+
"heading", "cell", "gridcell", "columnheader", "rowheader", "listitem",
|
|
481
|
+
"article", "region", "main", "navigation",
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _snapshot_ax_value(node, key, default=None):
|
|
486
|
+
value = node.get(key)
|
|
487
|
+
if isinstance(value, dict):
|
|
488
|
+
return value.get("value", default)
|
|
489
|
+
return default
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _snapshot_prop(node, name):
|
|
493
|
+
for prop in node.get("properties", []) or []:
|
|
494
|
+
if prop.get("name") == name:
|
|
495
|
+
value = prop.get("value")
|
|
496
|
+
if isinstance(value, dict):
|
|
497
|
+
return value.get("value")
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _snapshot_clean_name(value):
|
|
502
|
+
if value is None:
|
|
503
|
+
return ""
|
|
504
|
+
return " ".join(str(value).replace("\u00a0", " ").split())
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _snapshot_session(target_id=None):
|
|
508
|
+
if not target_id:
|
|
509
|
+
return None
|
|
510
|
+
return _bh.cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _snapshot_eval(expression, session_id=None):
|
|
514
|
+
result = _bh.cdp(
|
|
515
|
+
"Runtime.evaluate",
|
|
516
|
+
session_id=session_id,
|
|
517
|
+
expression=expression,
|
|
518
|
+
returnByValue=True,
|
|
519
|
+
awaitPromise=False,
|
|
520
|
+
)
|
|
521
|
+
details = result.get("exceptionDetails")
|
|
522
|
+
if details:
|
|
523
|
+
raise RuntimeError(details.get("text") or "snapshot JavaScript failed")
|
|
524
|
+
return (result.get("result") or {}).get("value")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _snapshot_set_ref(backend_node_id, ref_id, session_id=None):
|
|
528
|
+
resolved = _bh.cdp(
|
|
529
|
+
"DOM.resolveNode",
|
|
530
|
+
session_id=session_id,
|
|
531
|
+
backendNodeId=int(backend_node_id),
|
|
532
|
+
objectGroup="browser-workspace-snapshot",
|
|
533
|
+
)
|
|
534
|
+
object_id = (resolved.get("object") or {}).get("objectId")
|
|
535
|
+
if not object_id:
|
|
536
|
+
return False
|
|
537
|
+
result = _bh.cdp(
|
|
538
|
+
"Runtime.callFunctionOn",
|
|
539
|
+
session_id=session_id,
|
|
540
|
+
objectId=object_id,
|
|
541
|
+
functionDeclaration="function(ref){this.setAttribute('data-ref', ref); return true;}",
|
|
542
|
+
arguments=[{"value": ref_id}],
|
|
543
|
+
returnByValue=True,
|
|
544
|
+
)
|
|
545
|
+
return bool(((result.get("result") or {}).get("value")))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _snapshot_line(role, name, ref_id, node=None, extra=None):
|
|
549
|
+
attrs = [f"ref={ref_id}"]
|
|
550
|
+
if node:
|
|
551
|
+
level = _snapshot_prop(node, "level")
|
|
552
|
+
checked = _snapshot_prop(node, "checked")
|
|
553
|
+
expanded = _snapshot_prop(node, "expanded")
|
|
554
|
+
selected = _snapshot_prop(node, "selected")
|
|
555
|
+
disabled = _snapshot_prop(node, "disabled")
|
|
556
|
+
required = _snapshot_prop(node, "required")
|
|
557
|
+
value = _snapshot_prop(node, "value")
|
|
558
|
+
if level is not None:
|
|
559
|
+
attrs.append(f"level={level}")
|
|
560
|
+
if checked is not None:
|
|
561
|
+
attrs.append(f"checked={str(checked).lower()}")
|
|
562
|
+
if expanded is not None:
|
|
563
|
+
attrs.append(f"expanded={str(expanded).lower()}")
|
|
564
|
+
if selected is not None:
|
|
565
|
+
attrs.append(f"selected={str(selected).lower()}")
|
|
566
|
+
if disabled:
|
|
567
|
+
attrs.append("disabled=true")
|
|
568
|
+
if required:
|
|
569
|
+
attrs.append("required=true")
|
|
570
|
+
if value not in (None, ""):
|
|
571
|
+
attrs.append(f"value={_snapshot_clean_name(value)!r}")
|
|
572
|
+
if extra:
|
|
573
|
+
attrs.extend(extra)
|
|
574
|
+
label = f' "{name}"' if name else ""
|
|
575
|
+
return f"- {role}{label} [{', '.join(attrs)}]"
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def snapshot(interactive_only=True, target_id=None):
|
|
579
|
+
"""Return a compact accessibility snapshot and annotate live elements with data-ref.
|
|
580
|
+
|
|
581
|
+
The selection/format follows the useful parts of agent-browser's snapshot
|
|
582
|
+
approach, but refs are deliberately DOM-native: each returned ref is written
|
|
583
|
+
as ``data-ref=\"eN\"`` on the corresponding live element. Interact with the
|
|
584
|
+
result using normal Browser Harness CSS-selector helpers, e.g.
|
|
585
|
+
``click('[data-ref=\"e3\"]')`` or ``fill_input('[data-ref=\"e4\"]', 'text')``.
|
|
586
|
+
|
|
587
|
+
Re-run snapshot after navigation or major DOM changes; a new snapshot clears
|
|
588
|
+
and rewrites the page's data-ref attributes.
|
|
589
|
+
"""
|
|
590
|
+
session_id = _snapshot_session(target_id)
|
|
591
|
+
_bh.cdp("DOM.enable", session_id=session_id)
|
|
592
|
+
_bh.cdp("Accessibility.enable", session_id=session_id)
|
|
593
|
+
_snapshot_eval(
|
|
594
|
+
"document.querySelectorAll('[data-ref]').forEach(e=>e.removeAttribute('data-ref')); true",
|
|
595
|
+
session_id=session_id,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
ax = _bh.cdp("Accessibility.getFullAXTree", session_id=session_id)
|
|
599
|
+
nodes = ax.get("nodes", []) or []
|
|
600
|
+
lines = []
|
|
601
|
+
next_ref = 1
|
|
602
|
+
|
|
603
|
+
for node in nodes:
|
|
604
|
+
role = _snapshot_clean_name(_snapshot_ax_value(node, "role", ""))
|
|
605
|
+
name = _snapshot_clean_name(_snapshot_ax_value(node, "name", ""))
|
|
606
|
+
backend_node_id = node.get("backendDOMNodeId")
|
|
607
|
+
if not backend_node_id:
|
|
608
|
+
continue
|
|
609
|
+
should_include = role in _SNAPSHOT_INTERACTIVE_ROLES
|
|
610
|
+
if not interactive_only and role in _SNAPSHOT_CONTENT_ROLES and name:
|
|
611
|
+
should_include = True
|
|
612
|
+
if not should_include:
|
|
613
|
+
continue
|
|
614
|
+
ref_id = f"e{next_ref}"
|
|
615
|
+
try:
|
|
616
|
+
if not _snapshot_set_ref(backend_node_id, ref_id, session_id=session_id):
|
|
617
|
+
continue
|
|
618
|
+
except Exception:
|
|
619
|
+
continue
|
|
620
|
+
lines.append(_snapshot_line(role or "element", name, ref_id, node=node))
|
|
621
|
+
next_ref += 1
|
|
622
|
+
|
|
623
|
+
# Modern apps often use div/span elements with pointer handlers instead of
|
|
624
|
+
# semantic controls. agent-browser explicitly promotes those too. We do the
|
|
625
|
+
# same directly in the DOM, skipping elements already tagged through AX.
|
|
626
|
+
cursor_script = r"""
|
|
627
|
+
(() => {
|
|
628
|
+
const out = [];
|
|
629
|
+
const semanticTags = new Set(['a','button','input','select','textarea','details','summary']);
|
|
630
|
+
const semanticRoles = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']);
|
|
631
|
+
for (const el of document.body ? document.body.querySelectorAll('*') : []) {
|
|
632
|
+
if (el.hasAttribute('data-ref')) continue;
|
|
633
|
+
if (el.closest('[hidden],[aria-hidden="true"]')) continue;
|
|
634
|
+
const tag = el.tagName.toLowerCase();
|
|
635
|
+
if (semanticTags.has(tag)) continue;
|
|
636
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
637
|
+
if (semanticRoles.has(role)) continue;
|
|
638
|
+
const style = getComputedStyle(el);
|
|
639
|
+
const pointer = style.cursor === 'pointer';
|
|
640
|
+
const onclick = el.hasAttribute('onclick') || el.onclick !== null;
|
|
641
|
+
const tabindex = el.getAttribute('tabindex');
|
|
642
|
+
const focusable = tabindex !== null && tabindex !== '-1';
|
|
643
|
+
const ce = el.getAttribute('contenteditable');
|
|
644
|
+
const editable = ce === '' || ce === 'true';
|
|
645
|
+
if (!pointer && !onclick && !focusable && !editable) continue;
|
|
646
|
+
if (pointer && !onclick && !focusable && !editable && el.parentElement && getComputedStyle(el.parentElement).cursor === 'pointer') continue;
|
|
647
|
+
const rect = el.getBoundingClientRect();
|
|
648
|
+
if (!rect.width || !rect.height) continue;
|
|
649
|
+
out.push({
|
|
650
|
+
element: el,
|
|
651
|
+
role: role || (editable ? 'textbox' : 'clickable'),
|
|
652
|
+
name: (el.getAttribute('aria-label') || el.getAttribute('title') || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,160)
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return out.map(x => ({role:x.role,name:x.name}));
|
|
656
|
+
})()
|
|
657
|
+
"""
|
|
658
|
+
cursor_items = _snapshot_eval(cursor_script, session_id=session_id) or []
|
|
659
|
+
# The returned values intentionally omit object handles. Assign refs in one
|
|
660
|
+
# second DOM pass by the same deterministic filter/order.
|
|
661
|
+
if cursor_items:
|
|
662
|
+
assignment = _snapshot_eval(
|
|
663
|
+
f"""
|
|
664
|
+
(() => {{
|
|
665
|
+
let n = {next_ref};
|
|
666
|
+
const out = [];
|
|
667
|
+
const semanticTags = new Set(['a','button','input','select','textarea','details','summary']);
|
|
668
|
+
const semanticRoles = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']);
|
|
669
|
+
for (const el of document.body ? document.body.querySelectorAll('*') : []) {{
|
|
670
|
+
if (el.hasAttribute('data-ref')) continue;
|
|
671
|
+
if (el.closest('[hidden],[aria-hidden=\"true\"]')) continue;
|
|
672
|
+
const tag = el.tagName.toLowerCase();
|
|
673
|
+
if (semanticTags.has(tag)) continue;
|
|
674
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
675
|
+
if (semanticRoles.has(role)) continue;
|
|
676
|
+
const style = getComputedStyle(el);
|
|
677
|
+
const pointer = style.cursor === 'pointer';
|
|
678
|
+
const onclick = el.hasAttribute('onclick') || el.onclick !== null;
|
|
679
|
+
const tabindex = el.getAttribute('tabindex');
|
|
680
|
+
const focusable = tabindex !== null && tabindex !== '-1';
|
|
681
|
+
const ce = el.getAttribute('contenteditable');
|
|
682
|
+
const editable = ce === '' || ce === 'true';
|
|
683
|
+
if (!pointer && !onclick && !focusable && !editable) continue;
|
|
684
|
+
if (pointer && !onclick && !focusable && !editable && el.parentElement && getComputedStyle(el.parentElement).cursor === 'pointer') continue;
|
|
685
|
+
const rect = el.getBoundingClientRect();
|
|
686
|
+
if (!rect.width || !rect.height) continue;
|
|
687
|
+
const ref = 'e' + n++;
|
|
688
|
+
el.setAttribute('data-ref', ref);
|
|
689
|
+
out.push({{ref, role: role || (editable ? 'textbox' : 'clickable'), name:(el.getAttribute('aria-label') || el.getAttribute('title') || el.textContent || '').trim().replace(/\\s+/g,' ').slice(0,160)}});
|
|
690
|
+
}}
|
|
691
|
+
return out;
|
|
692
|
+
}})()
|
|
693
|
+
""",
|
|
694
|
+
session_id=session_id,
|
|
695
|
+
) or []
|
|
696
|
+
for item in assignment:
|
|
697
|
+
lines.append(_snapshot_line(item.get("role") or "clickable", _snapshot_clean_name(item.get("name")), item.get("ref")))
|
|
698
|
+
|
|
699
|
+
info = _bh.page_info() if target_id is None else None
|
|
700
|
+
header = []
|
|
701
|
+
if info and not info.get("dialog"):
|
|
702
|
+
header.append(f"Page: {info.get('title','')}")
|
|
703
|
+
header.append(f"URL: {info.get('url','')}")
|
|
704
|
+
return "\n".join(header + ([""] if header and lines else []) + lines)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def click(selector, target_id=None):
|
|
709
|
+
"""Click a CSS selector; snapshot refs are used as [data-ref="eN"]."""
|
|
710
|
+
selector_json = _json.dumps(selector)
|
|
711
|
+
ok = _bh.js(
|
|
712
|
+
f"(()=>{{const e=document.querySelector({selector_json});if(!e)return false;"
|
|
713
|
+
"e.scrollIntoView({block:'center',inline:'center'});e.click();return true;})()",
|
|
714
|
+
target_id=target_id,
|
|
715
|
+
)
|
|
716
|
+
if not ok:
|
|
717
|
+
raise RuntimeError(f"click: element not found: {selector!r}")
|
|
718
|
+
return True
|