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,131 +0,0 @@
1
- """macOS-only helpers for local Chrome automation."""
2
-
3
- from __future__ import annotations
4
-
5
- import platform
6
- import subprocess
7
- from pathlib import Path
8
-
9
- from .admin import daemon_browser_ready
10
- from .daemon import remote_debugging_toggle_profiles
11
-
12
- _APPLESCRIPT = r'''using terms from application "System Events"
13
- on clickAllow(nodeRef)
14
- try
15
- if (role of nodeRef as text) is "AXButton" and ¬
16
- (description of nodeRef as text) is "Allow" then
17
- perform action "AXPress" of nodeRef
18
- return true
19
- end if
20
- end try
21
- try
22
- repeat with childRef in UI elements of nodeRef
23
- if my clickAllow(childRef) then return true
24
- end repeat
25
- end try
26
- return false
27
- end clickAllow
28
- end using terms from
29
-
30
- set resultText to "not-found"
31
- tell application "System Events"
32
- if exists process "Google Chrome" then
33
- tell process "Google Chrome"
34
- repeat with w in windows
35
- try
36
- repeat with s in sheets of w
37
- if (name of s as text) is "Allow remote debugging?" then
38
- if my clickAllow(s) then
39
- set resultText to "ready"
40
- exit repeat
41
- end if
42
- end if
43
- end repeat
44
- end try
45
- if resultText is "ready" then exit repeat
46
- end repeat
47
- end tell
48
- end if
49
- end tell
50
- return resultText
51
- '''
52
-
53
-
54
- _ACCESSIBILITY_DETAIL = (
55
- "allow the app launching browser-harness (for example Terminal, iTerm, or Codex) "
56
- "in System Settings > Privacy & Security > Accessibility"
57
- )
58
-
59
-
60
- def _google_chrome_root() -> Path:
61
- return Path.home() / "Library/Application Support/Google/Chrome"
62
-
63
-
64
- def _google_chrome_toggle_enabled() -> bool:
65
- """Only accept the toggle from the Google Chrome root used by the script."""
66
- return _google_chrome_root() in remote_debugging_toggle_profiles()
67
-
68
-
69
- def approve_remote_debugging() -> tuple[str, str | None]:
70
- """Click Chrome's exact per-connection Allow sheet without activating Chrome."""
71
- if platform.system() != "Darwin":
72
- return "unsupported", "mac-approve is only available on macOS"
73
-
74
- if daemon_browser_ready():
75
- return "ready", None
76
-
77
- if not _google_chrome_toggle_enabled():
78
- return (
79
- "setup-required",
80
- 'first enable "Allow remote debugging for this browser instance" at '
81
- "chrome://inspect/#remote-debugging, then run `browser-harness mac-approve` again",
82
- )
83
-
84
- try:
85
- completed = subprocess.run(
86
- ["osascript"],
87
- input=_APPLESCRIPT,
88
- text=True,
89
- capture_output=True,
90
- timeout=5,
91
- check=False,
92
- )
93
- except subprocess.TimeoutExpired:
94
- return "accessibility-required", _ACCESSIBILITY_DETAIL
95
- except (OSError, subprocess.SubprocessError) as exc:
96
- return "error", str(exc)
97
-
98
- if completed.returncode != 0:
99
- detail = completed.stderr.strip() or "osascript failed"
100
- if "not authorized" in detail.lower() or "assistive" in detail.lower():
101
- return (
102
- "accessibility-required",
103
- _ACCESSIBILITY_DETAIL,
104
- )
105
- return "error", detail
106
-
107
- status = completed.stdout.strip()
108
- if status == "ready":
109
- return "ready", None
110
- if status == "not-found":
111
- # The user may have accepted the sheet while AppleScript was looking.
112
- if daemon_browser_ready():
113
- return "ready", None
114
- return (
115
- "not-found",
116
- "retry the browser command and run `browser-harness mac-approve` when the prompt appears",
117
- )
118
- return "error", f"unexpected osascript result: {status or '<empty>'}"
119
-
120
-
121
- def run_cli(args: list[str]) -> int:
122
- if args:
123
- print("usage: browser-harness mac-approve", flush=True)
124
- return 2
125
-
126
- status, detail = approve_remote_debugging()
127
- if detail:
128
- print(f"{status}: {detail}", flush=True)
129
- else:
130
- print(status, flush=True)
131
- return 0 if status == "ready" else 1
@@ -1,16 +0,0 @@
1
- """Console entry point for the optional Browser Harness MCP server."""
2
-
3
-
4
- def main() -> None:
5
- """Run the MCP server, or explain how to install its optional dependency."""
6
- try:
7
- from mcp_server import main as run_server
8
- except ModuleNotFoundError as exc:
9
- if exc.name == "mcp" or (exc.name and exc.name.startswith("mcp.")):
10
- raise SystemExit(
11
- "browser-harness-mcp requires MCP support. "
12
- "Install it with: pip install 'browser-harness[mcp]'"
13
- ) from None
14
- raise
15
-
16
- run_server()
@@ -1,49 +0,0 @@
1
- """browser-harness filesystem layout."""
2
- from __future__ import annotations
3
-
4
- import os
5
- import sys
6
- from pathlib import Path
7
-
8
-
9
- def home_dir() -> Path:
10
- raw = os.environ.get("BH_HOME") or os.environ.get("BROWSER_HARNESS_HOME")
11
- if raw:
12
- return Path(raw).expanduser().resolve()
13
- base = os.environ.get("XDG_CONFIG_HOME")
14
- if base:
15
- return (Path(base).expanduser() / "browser-harness").resolve()
16
- return (Path.home() / ".config" / "browser-harness").resolve()
17
-
18
-
19
- def ensure_private_dir(path: Path) -> Path:
20
- existed = path.exists()
21
- path.mkdir(parents=True, exist_ok=True)
22
- if not existed and sys.platform != "win32":
23
- os.chmod(path, 0o700)
24
- return path
25
-
26
-
27
- def config_dir() -> Path:
28
- raw = os.environ.get("BH_CONFIG_DIR")
29
- return ensure_private_dir(Path(raw).expanduser().resolve() if raw else home_dir())
30
-
31
-
32
- def inspect_marker() -> Path:
33
- """Marker recording that the harness opened a chrome://inspect tab"""
34
- return config_dir() / "inspect-opened"
35
-
36
-
37
- def runtime_dir() -> Path:
38
- raw = os.environ.get("BH_RUNTIME_DIR")
39
- return ensure_private_dir(Path(raw).expanduser().resolve() if raw else home_dir() / "runtime")
40
-
41
-
42
- def tmp_dir() -> Path:
43
- raw = os.environ.get("BH_TMP_DIR")
44
- return ensure_private_dir(Path(raw).expanduser().resolve() if raw else home_dir() / "tmp")
45
-
46
-
47
- def workspace_dir() -> Path:
48
- raw = os.environ.get("BH_AGENT_WORKSPACE")
49
- return ensure_private_dir(Path(raw).expanduser().resolve() if raw else home_dir() / "agent-workspace")
@@ -1,343 +0,0 @@
1
- """Session recording: one screenshot + one trace line per action.
2
-
3
- A recording is just a folder:
4
-
5
- <BH_AGENT_WORKSPACE>/recordings/<name>/
6
- meta.json # {name, title, started}
7
- events.jsonl # one JSON object per action: helper, coords/text,
8
- # url, viewport, focused-element box, frame filename
9
- 0001.jpg ... # viewport screenshot after each action
10
-
11
- start_recording()/stop_recording() toggle it. A marker file carries the
12
- active state across CLI invocations (the daemon is untouched). run.py calls
13
- observe() after every traced helper; only helpers in ACTIONS produce a
14
- frame. Recording failures are swallowed — they must never break the run.
15
-
16
- Automatic recording is an opt-in preference stored under the browser-harness
17
- config directory. BH_RECORD=1/0 overrides it for one process. Explicit
18
- start_recording() always works unless BH_RECORD=0 is set.
19
-
20
- Turning a recording into a video is the make-video skill's job:
21
- interaction-skills/make-video.md.
22
- """
23
- import base64, json, os, re, time
24
- from pathlib import Path
25
-
26
- from . import paths
27
-
28
- # Helpers that change what's on screen. Read-only helpers (js, page_info,
29
- # capture_screenshot, ...) don't get frames — they'd bloat recordings of
30
- # inspection-heavy sessions without adding visual beats.
31
- ACTIONS = {
32
- "goto_url", "click_at_xy", "type_text", "fill_input", "press_key",
33
- "scroll", "dispatch_key", "upload_file", "new_tab", "switch_tab",
34
- "close_tab", "ensure_real_tab",
35
- "wait", "wait_for_load", "wait_for_element", "wait_for_network_idle",
36
- }
37
-
38
- _TEXT_LIMIT = 500
39
- _SETTLE_SECONDS = 0.15 # let the page paint before the post-action frame
40
-
41
- # Credential-bearing query/fragment params (OAuth codes, tokens, session
42
- # state) are scrubbed from every URL written to events.jsonl — auth redirects
43
- # otherwise land real secrets in a folder people share.
44
- _URL_SECRETS = re.compile(
45
- r"([?&#](?:code|access_token|id_token|refresh_token|token|assertion"
46
- r"|client_secret|client_info|session_state|api_?key|sig|signature"
47
- r"|auth|authorization|password|secret)=)[^&#]+",
48
- re.IGNORECASE,
49
- )
50
-
51
-
52
- def _scrub_url(url):
53
- return _URL_SECRETS.sub(r"\1REDACTED", str(url))
54
-
55
- # Page context per event. The focused-element box is what lets a video zoom
56
- # in on the input the agent is typing into; `input` flags password fields
57
- # so their text can be masked in the trace.
58
- _CTX_JS = (
59
- "(()=>{const o={url:location.href,title:document.title,"
60
- "w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,dpr:devicePixelRatio};"
61
- "const e=document.activeElement;"
62
- "if(e&&e!==document.body&&e!==document.documentElement){"
63
- "const r=e.getBoundingClientRect();"
64
- "if(r.width||r.height)o.box={x:r.x,y:r.y,w:r.width,h:r.height};"
65
- "o.input=String(e.type||e.tagName||'').toLowerCase();}"
66
- "return o})()"
67
- )
68
-
69
-
70
- def _recordings_root():
71
- return paths.workspace_dir() / "recordings"
72
-
73
-
74
- def _config_path():
75
- return paths.config_dir() / "recording.json"
76
-
77
-
78
- def _load_config():
79
- try:
80
- data = json.loads(_config_path().read_text(encoding="utf-8"))
81
- return data if isinstance(data, dict) else {}
82
- except (FileNotFoundError, json.JSONDecodeError, OSError):
83
- return {}
84
-
85
-
86
- def _env_override():
87
- raw = os.environ.get("BH_RECORD")
88
- if raw is None:
89
- return None
90
- return raw.strip().lower() not in ("0", "false", "no", "off")
91
-
92
-
93
- def _marker():
94
- return _recordings_root() / f".active-{os.environ.get('BU_NAME', 'default')}"
95
-
96
-
97
- def start_recording(name=None, title=None):
98
- """Record the session: one screenshot + trace line per action, until
99
- stop_recording(). Survives across CLI invocations. Returns the recording
100
- directory. `title` is used later as the video title.
101
- See interaction-skills/make-video.md to turn the recording into a video."""
102
- if _env_override() is False:
103
- raise RuntimeError("recording disabled by BH_RECORD=0")
104
- name = name or time.strftime("rec-%Y%m%d-%H%M%S")
105
- d = _recordings_root() / name
106
- d.mkdir(parents=True, exist_ok=True)
107
- meta = {"name": name, "title": title, "started": round(time.time(), 3)}
108
- (d / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
109
- _marker().write_text(str(d), encoding="utf-8")
110
- _capture(d, "start_recording")
111
- print(f"recording to {d}")
112
- return str(d)
113
-
114
-
115
- def stop_recording():
116
- """Stop the active recording. Returns its directory, or None if idle."""
117
- d = recording_dir()
118
- if d is None:
119
- print("no active recording")
120
- return None
121
- _capture(Path(d), "stop_recording")
122
- _marker().unlink(missing_ok=True)
123
- frames = sum(1 for _ in Path(d).glob("*.jpg"))
124
- print(f"recording saved: {d} ({frames} frames)")
125
- if not frames:
126
- print(f"warning: no frames captured — see frame_error in {Path(d) / 'events.jsonl'}")
127
- return d
128
-
129
-
130
- def recording_dir():
131
- """Directory of the active recording, or None."""
132
- m = _marker()
133
- if not m.exists():
134
- return None
135
- d = m.read_text(encoding="utf-8").strip()
136
- return d if Path(d).is_dir() else None
137
-
138
-
139
- def recordings():
140
- """Recording directories, newest first."""
141
- root = _recordings_root()
142
- if not root.exists():
143
- return []
144
-
145
- def modified(path):
146
- evidence = path / "events.jsonl"
147
- return evidence.stat().st_mtime if evidence.exists() else path.stat().st_mtime
148
-
149
- found = [p for p in root.iterdir()
150
- if p.is_dir() and ((p / "meta.json").exists() or (p / "events.jsonl").exists())]
151
- return [str(p) for p in sorted(found, key=modified, reverse=True)]
152
-
153
-
154
- def latest_recording():
155
- """Newest recording directory, or None."""
156
- found = recordings()
157
- return found[0] if found else None
158
-
159
-
160
- def auto_recording_setting():
161
- """Return (enabled, source) for the automatic recording preference."""
162
- override = _env_override()
163
- if override is not None:
164
- return override, "BH_RECORD"
165
- config = _load_config()
166
- if isinstance(config.get("enabled"), bool):
167
- return config["enabled"], "config"
168
- return False, "default"
169
-
170
-
171
- def _auto_enabled():
172
- return auto_recording_setting()[0]
173
-
174
-
175
- def auto_recording_enabled():
176
- """Whether automatic recording is enabled for this process."""
177
- return _auto_enabled()
178
-
179
-
180
- def set_auto_recording(enabled):
181
- """Persist the automatic recording preference. BH_RECORD still overrides it."""
182
- path = _config_path()
183
- tmp = path.with_name(path.name + ".tmp")
184
- tmp.write_text(json.dumps({"enabled": bool(enabled)}) + "\n", encoding="utf-8")
185
- if os.name != "nt":
186
- os.chmod(tmp, 0o600)
187
- os.replace(tmp, path)
188
- if not enabled:
189
- d = recording_dir()
190
- if d is not None and _is_auto_recording(d):
191
- _marker().unlink(missing_ok=True)
192
-
193
-
194
- def _is_auto_recording(d):
195
- try:
196
- return json.loads((Path(d) / "meta.json").read_text(encoding="utf-8")).get("auto") is True
197
- except (FileNotFoundError, json.JSONDecodeError, OSError):
198
- return False
199
-
200
-
201
- # Auto-recordings roll over after this idle gap — a pause since the last action
202
- # marks the end of one task and the start of the next, so a naive always-on
203
- # recording doesn't merge unrelated sessions or grow forever.
204
- def _auto_idle_gap():
205
- try:
206
- return float(os.environ.get("BH_RECORD_IDLE", "180"))
207
- except ValueError:
208
- return 180.0
209
-
210
-
211
- def _auto_start():
212
- """Begin an auto-recording silently — no stdout (agents parse it)."""
213
- stamp = time.strftime("session-%Y%m%d-%H%M%S")
214
- name, n = stamp, 2
215
- while (_recordings_root() / name).exists(): # avoid same-second collisions
216
- name = f"{stamp}-{n}"; n += 1
217
- d = _recordings_root() / name
218
- d.mkdir(parents=True, exist_ok=True)
219
- meta = {"name": name, "title": None, "started": round(time.time(), 3), "auto": True}
220
- (d / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
221
- _marker().write_text(str(d), encoding="utf-8")
222
- return d
223
-
224
-
225
- def _auto_is_stale(d):
226
- """True if the active auto-recording has gone idle past the rollover gap."""
227
- try:
228
- if not _is_auto_recording(d):
229
- return False # explicit start_recording() never auto-rolls
230
- frames = list(Path(d).glob("*.jpg"))
231
- if not frames:
232
- return False
233
- newest = max(f.stat().st_mtime for f in frames)
234
- return (time.time() - newest) > _auto_idle_gap()
235
- except Exception:
236
- return False
237
-
238
-
239
- def observe(name, args, kwargs, duration=None):
240
- """Called by run.py after each traced helper succeeds. Never raises."""
241
- if name not in ACTIONS:
242
- return
243
- try:
244
- if _env_override() is False:
245
- return
246
- d = recording_dir()
247
- if d is not None and _is_auto_recording(d) and not _auto_enabled():
248
- _marker().unlink(missing_ok=True)
249
- d = None
250
- if d is not None and _auto_is_stale(d):
251
- _marker().unlink(missing_ok=True)
252
- d = None
253
- if d is None:
254
- if not _auto_enabled():
255
- return
256
- d = str(_auto_start())
257
- time.sleep(_SETTLE_SECONDS)
258
- _capture(Path(d), name, args, kwargs, duration)
259
- except Exception:
260
- pass
261
-
262
-
263
- def _capture(d, helper, args=(), kwargs=None, duration=None):
264
- from . import helpers # late: helpers imports recorder at its bottom
265
-
266
- event = {"ts": round(time.time(), 3), "helper": helper}
267
- if duration is not None:
268
- event["duration"] = duration
269
- try:
270
- event.update(helpers.js(_CTX_JS) or {})
271
- except Exception:
272
- pass
273
- event.update(_details(helper, args, kwargs or {}, event))
274
- for k in ("url", "to"):
275
- if k in event:
276
- event[k] = _scrub_url(event[k])
277
- try:
278
- # Same budget capture_screenshot() uses: a cloud screenshot routinely
279
- # exceeds the 5s default IPC timeout, and every frame here would time
280
- # out and be swallowed below, leaving a recording with no frames.
281
- shot = helpers.cdp(
282
- "Page.captureScreenshot",
283
- _response_timeout=helpers.SCREENSHOT_IPC_RESPONSE_TIMEOUT_SECONDS,
284
- format="jpeg",
285
- quality=80,
286
- )
287
- number = sum(1 for _ in d.glob("*.jpg")) + 1
288
- data = base64.b64decode(shot["data"])
289
- while True:
290
- frame = f"{number:04d}.jpg"
291
- try:
292
- with (d / frame).open("xb") as output:
293
- output.write(data)
294
- break
295
- except FileExistsError:
296
- number += 1
297
- event["frame"] = frame
298
- except Exception as e:
299
- # A dropped frame must not break the run, but it must not vanish
300
- # either — without this the only symptom is a recording that ends up
301
- # with fewer frames than actions, and no way to tell why.
302
- # `str(e) or ...` because an exception raised bare (no args) stringifies
303
- # to "", which would record a useless "SomeError: " and defeat the point.
304
- event["frame_error"] = f"{type(e).__name__}: {str(e) or 'no detail'}"[:200]
305
- # Page-controlled strings (document.title etc.) can carry lone surrogates
306
- # in through CDP's \uDXXX escapes; they can't encode as UTF-8, and one
307
- # would kill the whole trace. Replace with U+FFFD — same medicine run.py
308
- # already gives stdout (#124).
309
- with (d / "events.jsonl").open("a", encoding="utf-8", errors="replace") as f:
310
- f.write(json.dumps(event, ensure_ascii=False) + "\n")
311
-
312
-
313
- def _details(helper, args, kwargs, ctx):
314
- def arg(i, name, default=None):
315
- return args[i] if len(args) > i else kwargs.get(name, default)
316
-
317
- d = {}
318
- if helper == "click_at_xy":
319
- d["x"], d["y"] = arg(0, "x"), arg(1, "y")
320
- elif helper == "scroll":
321
- d["x"], d["y"] = arg(0, "x"), arg(1, "y")
322
- d["dy"], d["dx"] = arg(2, "dy", -300), arg(3, "dx", 0)
323
- elif helper in ("goto_url", "new_tab"):
324
- d["to"] = arg(0, "url")
325
- elif helper == "type_text":
326
- d["text"] = _mask(arg(0, "text", ""), ctx)
327
- elif helper == "fill_input":
328
- d["selector"] = arg(0, "selector")
329
- d["text"] = _mask(arg(1, "text", ""), ctx)
330
- elif helper == "press_key":
331
- d["key"] = arg(0, "key")
332
- elif helper == "dispatch_key":
333
- d["selector"], d["key"] = arg(0, "selector"), arg(1, "key", "Enter")
334
- elif helper == "wait_for_element":
335
- d["selector"] = arg(0, "selector")
336
- return {k: v for k, v in d.items() if v is not None}
337
-
338
-
339
- def _mask(text, ctx):
340
- text = str(text)
341
- if ctx.get("input") == "password":
342
- return "•" * len(text)
343
- return text[:_TEXT_LIMIT]