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
@@ -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