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.
Files changed (108) hide show
  1. package/PRIVACY.md +21 -0
  2. package/README.md +138 -0
  3. package/SKILL.md +301 -0
  4. package/agent-workspace/agent_helpers.py +718 -0
  5. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  6. package/bin/browser-workspace +75 -0
  7. package/extension/manifest.json +26 -0
  8. package/extension/service-worker.mjs +126 -0
  9. package/extension/workspace-manager.mjs +680 -0
  10. package/extension/workspace-page.mjs +7 -0
  11. package/extension/workspace.html +8 -0
  12. package/interaction-skills/connection.md +52 -0
  13. package/interaction-skills/cookies.md +3 -0
  14. package/interaction-skills/cross-origin-iframes.md +3 -0
  15. package/interaction-skills/dialogs.md +64 -0
  16. package/interaction-skills/downloads.md +3 -0
  17. package/interaction-skills/drag-and-drop.md +3 -0
  18. package/interaction-skills/dropdowns.md +3 -0
  19. package/interaction-skills/iframes.md +3 -0
  20. package/interaction-skills/network-requests.md +3 -0
  21. package/interaction-skills/print-as-pdf.md +3 -0
  22. package/interaction-skills/profile-sync.md +90 -0
  23. package/interaction-skills/screenshots.md +17 -0
  24. package/interaction-skills/scrolling.md +22 -0
  25. package/interaction-skills/shadow-dom.md +3 -0
  26. package/interaction-skills/tabs.md +73 -0
  27. package/interaction-skills/uploads.md +1 -0
  28. package/interaction-skills/viewport.md +16 -0
  29. package/licenses/browser-harness-MIT.txt +21 -0
  30. package/managed_chrome.py +244 -0
  31. package/node/admin.mjs +44 -0
  32. package/package.json +36 -0
  33. package/platform_runner.py +187 -0
  34. package/platforms/chatgpt/README.md +60 -0
  35. package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  36. package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  37. package/platforms/chatgpt/actions/_mcp_app.py +80 -0
  38. package/platforms/chatgpt/actions/_new_thread.py +85 -0
  39. package/platforms/chatgpt/actions/_new_turn.py +33 -0
  40. package/platforms/chatgpt/actions/_project_setup.py +353 -0
  41. package/platforms/chatgpt/actions/_readiness.py +52 -0
  42. package/platforms/chatgpt/actions/_submit_thread.py +129 -0
  43. package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
  44. package/platforms/chatgpt/actions/_thread_turn.py +362 -0
  45. package/platforms/chatgpt/manifest.yaml +34 -0
  46. package/platforms/chatgpt/tests/test_contract.py +176 -0
  47. package/platforms/chrome-web-store/README.md +49 -0
  48. package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  49. package/platforms/chrome-web-store/flows/check-status.md +7 -0
  50. package/platforms/chrome-web-store/flows/open-item.md +10 -0
  51. package/platforms/chrome-web-store/flows/submit-review.md +10 -0
  52. package/platforms/chrome-web-store/flows/update-listing.md +18 -0
  53. package/platforms/chrome-web-store/flows/upload-package.md +11 -0
  54. package/platforms/chrome-web-store/flows/verify-published.md +9 -0
  55. package/platforms/chrome-web-store/manifest.yaml +33 -0
  56. package/platforms/chrome-web-store/tests/test_contract.py +83 -0
  57. package/platforms/microsoft-teams/README.md +32 -0
  58. package/platforms/microsoft-teams/actions/_meeting.py +210 -0
  59. package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  60. package/platforms/microsoft-teams/manifest.yaml +25 -0
  61. package/platforms/microsoft-teams/tests/test_contract.py +24 -0
  62. package/platforms/tiktok/README.md +10 -0
  63. package/platforms/tiktok/actions/_post.py +180 -0
  64. package/platforms/tiktok/manifest.yaml +99 -0
  65. package/platforms/wechat-channels/README.md +10 -0
  66. package/platforms/wechat-channels/actions/_common.py +163 -0
  67. package/platforms/wechat-channels/actions/_manage.py +130 -0
  68. package/platforms/wechat-channels/actions/_post.py +232 -0
  69. package/platforms/wechat-channels/manifest.yaml +118 -0
  70. package/platforms/wechat-channels/tests/test_contract.py +36 -0
  71. package/platforms/xhs/README.md +10 -0
  72. package/platforms/xhs/actions/_manage.py +355 -0
  73. package/platforms/xhs/actions/_post.py +192 -0
  74. package/platforms/xhs/manifest.yaml +98 -0
  75. package/platforms/youtube/README.md +10 -0
  76. package/platforms/youtube/actions/_post.py +100 -0
  77. package/platforms/youtube/manifest.yaml +94 -0
  78. package/pyproject.toml +29 -0
  79. package/session_client.py +97 -0
  80. package/session_daemon.py +325 -0
  81. package/src/browser_harness/__init__.py +2 -0
  82. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  83. package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
  84. package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
  85. package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
  86. package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
  87. package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
  88. package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
  89. package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
  90. package/src/browser_harness/_ipc.py +201 -0
  91. package/src/browser_harness/admin.py +1588 -0
  92. package/src/browser_harness/auth.py +546 -0
  93. package/src/browser_harness/daemon.py +894 -0
  94. package/src/browser_harness/helpers.py +668 -0
  95. package/src/browser_harness/macos.py +131 -0
  96. package/src/browser_harness/mcp_cli.py +16 -0
  97. package/src/browser_harness/paths.py +49 -0
  98. package/src/browser_harness/recorder.py +343 -0
  99. package/src/browser_harness/run.py +413 -0
  100. package/src/browser_harness/telemetry.py +308 -0
  101. package/src/browser_harness/video-template.html +970 -0
  102. package/src/browser_harness/video.py +749 -0
  103. package/src/browser_harness/video_render.py +524 -0
  104. package/src/browser_workspace.egg-info/PKG-INFO +10 -0
  105. package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
  106. package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
  107. package/src/browser_workspace.egg-info/requires.txt +5 -0
  108. package/src/browser_workspace.egg-info/top_level.txt +1 -0
@@ -0,0 +1,308 @@
1
+ """Best-effort, opt-out telemetry for browser-harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import uuid
12
+ from importlib.metadata import PackageNotFoundError, version
13
+ from pathlib import Path
14
+ from urllib.parse import urlparse
15
+
16
+ from . import paths
17
+
18
+ POSTHOG_KEY = "phc_rCPCLPtaXB3EuBdiH7JLKtU2Wj5iPnuwdsbw58CnjYXc"
19
+ POSTHOG_HOST = "https://eu.i.posthog.com"
20
+ DISABLE_ENVS = ("BH_TELEMETRY", "BROWSER_HARNESS_TELEMETRY", "ANONYMIZED_TELEMETRY")
21
+ MAX_TASK_LENGTH = 20_000
22
+ FORBIDDEN_KEYS = (
23
+ "api_key",
24
+ "content",
25
+ "cookie",
26
+ "email",
27
+ "href",
28
+ "key",
29
+ "message",
30
+ "password",
31
+ "path",
32
+ "prompt",
33
+ "query",
34
+ "secret",
35
+ "selector",
36
+ "text",
37
+ "title",
38
+ "token",
39
+ "url",
40
+ "uri",
41
+ )
42
+
43
+
44
+ def _config_dir() -> Path:
45
+ return paths.config_dir()
46
+
47
+
48
+ def _config_path() -> Path:
49
+ return _config_dir() / "telemetry.json"
50
+
51
+
52
+ def _load_config() -> dict:
53
+ try:
54
+ return json.loads(_config_path().read_text(encoding="utf-8"))
55
+ except (FileNotFoundError, OSError, ValueError):
56
+ return {}
57
+
58
+
59
+ def _save_config(data: dict) -> None:
60
+ path = _config_path()
61
+ try:
62
+ parent_existed = path.parent.exists()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ if not parent_existed and platform.system() != "Windows":
65
+ os.chmod(path.parent, 0o700)
66
+ path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
67
+ if platform.system() != "Windows":
68
+ os.chmod(path, 0o600)
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ def _version() -> str:
74
+ try:
75
+ return version("browser-harness")
76
+ except PackageNotFoundError:
77
+ return ""
78
+ except Exception:
79
+ return ""
80
+
81
+
82
+ def _env_disabled() -> bool:
83
+ return any((os.environ.get(name) or "").lower() in {"0", "false", "no", "off"} for name in DISABLE_ENVS)
84
+
85
+
86
+ def _valid_install_id(raw) -> bool:
87
+ return isinstance(raw, str) and re.fullmatch(r"[0-9a-f-]{32,36}", raw) is not None
88
+
89
+
90
+ def _install_id(config: dict | None = None, *, create: bool = True) -> str | None:
91
+ config = config if config is not None else _load_config()
92
+ raw = config.get("install_id")
93
+ if _valid_install_id(raw):
94
+ return raw
95
+ if not create:
96
+ return None
97
+ install_id = str(uuid.uuid4())
98
+ _save_config({**config, "install_id": install_id})
99
+ return install_id
100
+
101
+
102
+ def is_enabled() -> bool:
103
+ if _env_disabled():
104
+ return False
105
+ return not bool(_load_config().get("disabled"))
106
+
107
+
108
+ def status() -> dict:
109
+ config = _load_config()
110
+ env_disabled = _env_disabled()
111
+ enabled = not env_disabled and not bool(config.get("disabled"))
112
+ return {
113
+ "enabled": enabled,
114
+ "disabled_by_env": env_disabled,
115
+ "disabled_by_config": bool(config.get("disabled")),
116
+ "install_id": _install_id(config, create=enabled),
117
+ "config_path": str(_config_path()),
118
+ }
119
+
120
+
121
+ def set_enabled(enabled: bool) -> dict:
122
+ config = _load_config()
123
+ config["disabled"] = not enabled
124
+ _save_config(config)
125
+ return status()
126
+
127
+
128
+ def _safe_properties(properties: dict | None) -> dict:
129
+ out = {}
130
+ for key, value in (properties or {}).items():
131
+ safe_key = re.sub(r"[^A-Za-z0-9_$.-]+", "_", str(key))[:80]
132
+ lowered = safe_key.lower()
133
+ if not safe_key or any(word in lowered for word in FORBIDDEN_KEYS):
134
+ continue
135
+ if isinstance(value, bool) or value is None:
136
+ out[safe_key] = value
137
+ elif isinstance(value, int | float):
138
+ out[safe_key] = value
139
+ else:
140
+ safe_value = str(value)
141
+ if "://" in safe_value:
142
+ safe_value = "[redacted]"
143
+ out[safe_key] = safe_value[:120]
144
+ return out
145
+
146
+
147
+ # Env markers each coding agent injects into subprocesses
148
+ _AGENT_ENV_MARKERS: tuple[tuple[str, str], ...] = (
149
+ ("AGENT=amp", "amp"),
150
+ ("CLAUDECODE", "claude-code"),
151
+ ("CODEX_SANDBOX", "codex"),
152
+ ("CODEX_THREAD_ID", "codex"),
153
+ ("GEMINI_CLI", "gemini-cli"),
154
+ ("COPILOT_CLI", "copilot-cli"),
155
+ ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
156
+ ("OPENCLAW_CLI", "openclaw"),
157
+ ("HERMES_SESSION_ID", "hermes"),
158
+ ("CURSOR_AGENT", "cursor"),
159
+ ("CURSOR_TRACE_ID", "cursor"),
160
+ ("OPENCODE", "opencode"),
161
+ )
162
+
163
+
164
+ def _detect_agent_client() -> str | None:
165
+ for marker, client in _AGENT_ENV_MARKERS:
166
+ name, _, required = marker.partition("=")
167
+ value = os.environ.get(name)
168
+ if value and (not required or value == required):
169
+ return client
170
+ return None
171
+
172
+
173
+ _DETACHED_SENDER_SOURCE = """
174
+ import json, sys, urllib.request
175
+ try:
176
+ job = json.load(sys.stdin)
177
+ request = urllib.request.Request(
178
+ job['url'],
179
+ method='POST',
180
+ data=json.dumps(job['payload']).encode('utf-8'),
181
+ headers={'Content-Type': 'application/json', 'User-Agent': 'browser-harness'},
182
+ )
183
+ urllib.request.urlopen(request, timeout=job['timeout']).close()
184
+ except Exception:
185
+ pass
186
+ """
187
+
188
+
189
+ def _send_detached(payload: dict) -> None:
190
+ """Hand the event to a detached helper process so the CLI never blocks."""
191
+ host = os.environ.get("BH_POSTHOG_HOST", POSTHOG_HOST).rstrip("/")
192
+ job = {
193
+ "url": f"{host}/i/v0/e/",
194
+ "timeout": float(os.environ.get("BH_TELEMETRY_TIMEOUT", "5")),
195
+ "payload": payload,
196
+ }
197
+ process = subprocess.Popen(
198
+ [sys.executable, "-c", _DETACHED_SENDER_SOURCE],
199
+ stdin=subprocess.PIPE,
200
+ stdout=subprocess.DEVNULL,
201
+ stderr=subprocess.DEVNULL,
202
+ start_new_session=True,
203
+ )
204
+ assert process.stdin is not None
205
+ process.stdin.write(json.dumps(job).encode("utf-8"))
206
+ process.stdin.close()
207
+
208
+
209
+ def _base_properties() -> dict:
210
+ return {
211
+ "browser_harness_version": _version() or "unknown",
212
+ "python_version": platform.python_version(),
213
+ "os": platform.system() or "unknown",
214
+ "machine": platform.machine() or "unknown",
215
+ }
216
+
217
+
218
+ def _cdp_hostname() -> str | None:
219
+ value = os.environ.get("BU_CDP_WS") or os.environ.get("BU_CDP_URL")
220
+ if not value:
221
+ return None
222
+ try:
223
+ return urlparse(value if "://" in value else f"//{value}").hostname
224
+ except ValueError:
225
+ return None
226
+
227
+
228
+ def capture(event: str, properties: dict | None = None) -> None:
229
+ if not is_enabled():
230
+ return
231
+ try:
232
+ payload = {
233
+ "api_key": POSTHOG_KEY,
234
+ "distinct_id": _install_id(),
235
+ "event": event,
236
+ "properties": {
237
+ **_base_properties(),
238
+ "$process_person_profile": False,
239
+ **_safe_properties(properties),
240
+ },
241
+ }
242
+ _send_detached(payload)
243
+ except Exception:
244
+ return
245
+
246
+
247
+ def capture_cli_event(
248
+ *,
249
+ action: str,
250
+ command: str,
251
+ task: str | None = None,
252
+ browser: str | None = None,
253
+ output: str | None = None,
254
+ output_length: int | None = None,
255
+ steps: list | None = None,
256
+ step_count: int | None = None,
257
+ duration_seconds: float | None = None,
258
+ exit_code: int | None = None,
259
+ error_message: str | None = None,
260
+ ) -> None:
261
+ if not is_enabled():
262
+ return
263
+ try:
264
+ payload = {
265
+ "api_key": POSTHOG_KEY,
266
+ "distinct_id": _install_id(),
267
+ "event": "cli_event",
268
+ "properties": {
269
+ **_base_properties(),
270
+ "$process_person_profile": True,
271
+ "action": action,
272
+ "command": command,
273
+ # 'cloud' | 'cdp' | 'local'
274
+ "browser": browser,
275
+ "cdp_url": _cdp_hostname(),
276
+ "client": os.environ.get("BH_CLIENT") or None,
277
+ "client_version": os.environ.get("BH_CLIENT_VERSION") or None,
278
+ "agent_client": _detect_agent_client(),
279
+ "model": os.environ.get("BROWSER_USE_AGENT_MODEL") or None,
280
+ "model_provider": os.environ.get("BROWSER_USE_MODEL_PROVIDER") or None,
281
+ "task": task[:MAX_TASK_LENGTH] if task is not None else None,
282
+ "task_length": len(task) if task is not None else None,
283
+ "output": output,
284
+ "output_length": output_length,
285
+ "steps": steps,
286
+ "step_count": step_count,
287
+ "duration_seconds": duration_seconds,
288
+ "exit_code": exit_code,
289
+ "error_message": error_message,
290
+ },
291
+ }
292
+ _send_detached(payload)
293
+ except Exception:
294
+ return
295
+
296
+
297
+ def run_telemetry_cli(argv: list[str]) -> int:
298
+ if not argv or argv == ["status"]:
299
+ print(json.dumps(status(), indent=2))
300
+ return 0
301
+ if argv == ["disable"]:
302
+ print(json.dumps(set_enabled(False), indent=2))
303
+ return 0
304
+ if argv == ["enable"]:
305
+ print(json.dumps(set_enabled(True), indent=2))
306
+ return 0
307
+ print("usage: browser-harness telemetry [status|enable|disable]")
308
+ return 2