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.
- package/README.md +16 -2
- package/SKILL.md +60 -37
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +174 -13
- package/package.json +1 -1
- package/platform_runner.py +23 -6
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/tests/test_contract.py +7 -0
- package/platforms/wechat-channels/actions/_common.py +1 -1
- package/platforms/wechat-channels/actions/_post.py +37 -38
- package/platforms/wechat-channels/manifest.yaml +19 -13
- package/platforms/xhs/actions/_post.py +109 -20
- package/platforms/xhs/manifest.yaml +14 -5
- package/platforms/xhs/tests/test_contract.py +17 -0
- package/platforms/youtube/actions/_post.py +7 -4
- package/pyproject.toml +6 -2
- package/session_client.py +21 -11
- package/session_daemon.py +115 -9
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/platform_runner.py +204 -0
- package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
- package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +107 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- 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]
|