browser-workspace 0.4.2 → 0.4.9

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 (73) hide show
  1. package/README.md +15 -1
  2. package/SKILL.md +22 -21
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/package.json +1 -1
  5. package/platform_runner.py +11 -2
  6. package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
  7. package/platforms/chatgpt/actions/_new_turn.py +1 -0
  8. package/platforms/chatgpt/actions/_readiness.py +29 -3
  9. package/platforms/chatgpt/actions/_submit_thread.py +20 -3
  10. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  11. package/platforms/chatgpt/actions/_thread_turn.py +31 -2
  12. package/platforms/chatgpt/tests/test_contract.py +114 -1
  13. package/platforms/iyf/AIRPLAY.md +25 -0
  14. package/platforms/iyf/actions/_find.py +18 -0
  15. package/platforms/iyf/actions/_open.py +10 -0
  16. package/platforms/iyf/manifest.yaml +11 -0
  17. package/platforms/iyf/safari-airplay +57 -0
  18. package/pyproject.toml +6 -2
  19. package/session_client.py +10 -0
  20. package/session_daemon.py +91 -3
  21. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  22. package/src/browser_workspace.egg-info/SOURCES.txt +91 -15
  23. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  24. package/src/browser_workspace.egg-info/requires.txt +1 -0
  25. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  26. package/src/browser_workspace_app/__init__.py +1 -0
  27. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  28. package/src/browser_workspace_app/cli.py +236 -0
  29. package/src/browser_workspace_app/extension/manifest.json +26 -0
  30. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  31. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  32. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  33. package/src/browser_workspace_app/extension/workspace.html +8 -0
  34. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  35. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  36. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  37. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  38. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  39. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  40. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  41. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  42. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  43. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  44. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  45. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  46. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  47. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  48. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  49. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  50. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  51. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  52. package/src/browser_workspace_app/managed_chrome.py +244 -0
  53. package/src/browser_workspace_app/node/admin.mjs +44 -0
  54. package/src/browser_workspace_app/package.json +36 -0
  55. package/src/browser_workspace_app/pyproject.toml +33 -0
  56. package/src/browser_workspace_app/session_client.py +117 -0
  57. package/src/browser_workspace_app/session_daemon.py +431 -0
  58. package/src/browser_harness/__init__.py +0 -2
  59. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  60. package/src/browser_harness/_ipc.py +0 -201
  61. package/src/browser_harness/admin.py +0 -1588
  62. package/src/browser_harness/auth.py +0 -546
  63. package/src/browser_harness/daemon.py +0 -894
  64. package/src/browser_harness/helpers.py +0 -668
  65. package/src/browser_harness/macos.py +0 -131
  66. package/src/browser_harness/mcp_cli.py +0 -16
  67. package/src/browser_harness/paths.py +0 -49
  68. package/src/browser_harness/recorder.py +0 -343
  69. package/src/browser_harness/run.py +0 -413
  70. package/src/browser_harness/telemetry.py +0 -308
  71. package/src/browser_harness/video-template.html +0 -970
  72. package/src/browser_harness/video.py +0 -749
  73. package/src/browser_harness/video_render.py +0 -524
@@ -34,7 +34,8 @@ def test_action_uses_browser_helpers_without_infrastructure_import():
34
34
  assert "switch_tab(target_id)" in text
35
35
  assert "new_tab(thread_url)" in text
36
36
  assert '"recovered":recovered' in text
37
- assert "from _readiness import wait_until_stable, submission_receipt" in text
37
+ assert "from _readiness import" in text
38
+ assert "submission_receipt" in text
38
39
 
39
40
 
40
41
  def test_project_setup_supports_required_inputs():
@@ -68,6 +69,44 @@ def test_prompt_match_tolerates_block_tag_boundary_whitespace():
68
69
  assert ns["prompt_text_matches"](observed, expected)
69
70
 
70
71
 
72
+ def test_submission_receipt_requires_accepted_user_turn():
73
+ ns = {}
74
+ exec((ACTIONS / "_readiness.py").read_text(), ns)
75
+ accepted_turn = {"id": "msg_1", "text": "run the repair"}
76
+ assert ns["submission_receipt"]([accepted_turn], 0, "run the repair", "") == {
77
+ "verified_by": "user-turn",
78
+ "turn": accepted_turn,
79
+ }
80
+ assert ns["submission_receipt"]([], 0, "run the repair", "") is None
81
+ assert ns["submission_receipt"]([], 0, "run the repair", "run the repair") is None
82
+
83
+
84
+ def test_submission_failure_preserves_bounded_fallback_diagnostic():
85
+ ns = {}
86
+ exec((ACTIONS / "_readiness.py").read_text(), ns)
87
+ existing_turn = {"id": "old", "text": "old"}
88
+ diagnostic = ns["submission_failure_diagnostic"](
89
+ [existing_turn],
90
+ 1,
91
+ "run the repair",
92
+ "run the repair",
93
+ {"composer_present": True, "composer_text_length": 14, "send_enabled": True},
94
+ {"composer_present": False, "composer_text_length": 0, "send_enabled": False},
95
+ )
96
+ assert diagnostic == {
97
+ "accepted": False,
98
+ "reason": "no_accepted_user_turn",
99
+ "user_turn_matched": False,
100
+ "before_user_turn_count": 1,
101
+ "user_turn_count": 1,
102
+ "composer_populated": True,
103
+ "composer_text_length": 14,
104
+ "ui_changed": True,
105
+ "ui_before": {"composer_present": True, "composer_text_length": 14, "send_enabled": True},
106
+ "ui_after": {"composer_present": False, "composer_text_length": 0, "send_enabled": False},
107
+ }
108
+
109
+
71
110
  def test_wait_until_stable_retries_transient_read_errors():
72
111
  ns = {}
73
112
  exec((ACTIONS / "_readiness.py").read_text(), ns)
@@ -89,6 +128,29 @@ def test_wait_until_stable_retries_transient_read_errors():
89
128
  assert calls["n"] == 3
90
129
 
91
130
 
131
+ def test_wait_until_stable_requires_same_ready_state():
132
+ ns = {}
133
+ exec((ACTIONS / "_readiness.py").read_text(), ns)
134
+ states = iter([{"selector": "textarea"}, {"selector": "#composer"}, {"selector": "#composer"}])
135
+ result = ns["wait_until_stable"](
136
+ lambda: next(states),
137
+ lambda state: bool(state["selector"]),
138
+ timeout=0.2,
139
+ interval=0.001,
140
+ stable_samples=2,
141
+ phase="test",
142
+ )
143
+ assert result == {"selector": "#composer"}
144
+
145
+
146
+ def test_fill_prompt_prefers_canonical_composer_before_fallback():
147
+ text=(ACTIONS / "_lifecycle_common.py").read_text()
148
+ fill_prompt=text.split("def fill_prompt(",1)[1].split("def submit(",1)[0]
149
+ assert fill_prompt.index("primary_composer_selector(),") < fill_prompt.index("composer_selector(),")
150
+ assert "canonical composer readiness" in text
151
+ assert "ChatGPT composer readiness" in text
152
+
153
+
92
154
  def test_submit_thread_is_submission_only():
93
155
  text=(ACTIONS/"_submit_thread.py").read_text()
94
156
  assert 'project_id' in text and 'thread_id' in text
@@ -98,6 +160,15 @@ def test_submit_thread_is_submission_only():
98
160
  assert "app_name" in text and "attach_app_and_restore_prompt" in text
99
161
 
100
162
 
163
+ def test_submit_thread_fails_closed_with_diagnostic():
164
+ text = (ACTIONS / "_submit_thread.py").read_text()
165
+ submit = text.split("if not click_visible_send():", 1)[1]
166
+ assert "SubmissionVerificationError" in text
167
+ assert "submission_failure_diagnostic(" in submit
168
+ assert "ui_before" in submit
169
+ assert "composer-cleared" not in text
170
+
171
+
101
172
  def test_manifest_declares_mcp_app_action():
102
173
  text = (ROOT / "manifest.yaml").read_text()
103
174
  assert 'mcp-app: "actions/_mcp_app.py"' in text
@@ -142,6 +213,25 @@ def test_lifecycle_actions_use_stable_ids_and_do_not_wait_for_new_answer():
142
213
  assert "submission_receipt" in common
143
214
 
144
215
 
216
+ def test_lifecycle_submission_fails_closed_with_diagnostic():
217
+ common = (ACTIONS / "_lifecycle_common.py").read_text()
218
+ submit = common.split("def submit(prompt):", 1)[1]
219
+ assert "SubmissionVerificationError" in common
220
+ assert "verification_ui_state(selector)" in common
221
+ assert "submission_failure_diagnostic(" in submit
222
+ assert "ui_before" in submit
223
+ assert "composer-cleared" not in common
224
+
225
+
226
+ def test_thread_turn_fails_closed_with_diagnostic():
227
+ text = (ACTIONS / "_thread_turn.py").read_text()
228
+ submit = text.split("def _submit(prompt, files, target_id):", 1)[1].split("def _result(", 1)[0]
229
+ assert "SubmissionVerificationError" in text
230
+ assert "submission_failure_diagnostic(" in submit
231
+ assert "ui_before" in submit
232
+ assert "composer-cleared" not in text
233
+
234
+
145
235
  def test_new_thread_supports_projectless_and_temporary_modes():
146
236
  thread=(ACTIONS/"_new_thread.py").read_text()
147
237
  assert 'temporary=bool(CFG.get("temporary", False))' in thread
@@ -174,3 +264,26 @@ def test_new_turn_project_id_is_optional():
174
264
  assert 'if not thread_id: raise RuntimeError("thread_id is required")' in turn
175
265
  assert 'https://chatgpt.com/c/{quote(thread_id' in turn
176
266
  assert 'https://chatgpt.com/g/{quote(project_id' in turn
267
+
268
+
269
+ def test_temporary_submit_recovers_only_before_send_from_prefill_hydration():
270
+ code=(ACTIONS/"_temporary_submit.py").read_text()
271
+ assert 'goto_url(temporary_chat_entry_url())' in code
272
+ assert '"composer readiness was not observed ready" not in str(exc)' in code
273
+ assert 'if not _temporary_chat_enabled():' in code
274
+
275
+
276
+ def test_thread_url_returned_by_new_turn_and_submit_thread():
277
+ from pathlib import Path
278
+ actions = Path(__file__).resolve().parents[1] / "actions"
279
+ for name in ("_new_turn.py", "_submit_thread.py"):
280
+ source = (actions / name).read_text(encoding="utf-8")
281
+ assert "\"thread_url\":thread_url" in source
282
+
283
+
284
+ def test_generic_contenteditable_fallback_is_unique_and_excludes_pending_home_input():
285
+ text=(ACTIONS / "_lifecycle_common.py").read_text()
286
+ part=text.split("def composer_selector():",1)[1].split("def primary_composer_selector():",1)[0]
287
+ assert "generic.length===1" in part
288
+ assert "pending-home-input" in part
289
+ assert "[contenteditable=\"true\"]:not(#pending-home-input)" in part
@@ -0,0 +1,25 @@
1
+ # IYF Safari → Roku AirPlay 操作手册
2
+
3
+ ## 已验证环境(2026-10-11)
4
+ - Mac 上 Safari 播放爱壹帆视频,Roku 设备名:`43in MAGNAVOX Roku TV`。
5
+ - CLI:`/Users/chengli/Workspace/browser-workspace/platforms/iyf/safari-airplay`。
6
+ - 《沙丘》播放地址:`https://www.iyf.tv/play/Ob1Hrzz7x7t`。
7
+ - 已执行 3 次 Safari 退出、重新打开《沙丘》、CLI 连接的冷启动测试,3/3 返回 `CONNECTED`;不代表长时间播放或《沙丘2》已验证。
8
+
9
+ ## 新会话操作
10
+ 1. 通过 NeoY 在 Mac 上执行 `open -a Safari 'https://www.iyf.tv/play/Ob1Hrzz7x7t'`(其他影片先找到其正确 URL)。
11
+ 2. 确保 Safari 当前标签页中 HTML video 已开始播放;如需要,通过 Safari UI 操作播放。
12
+ 3. 执行 `zsh /Users/chengli/Workspace/browser-workspace/platforms/iyf/safari-airplay '43in MAGNAVOX Roku TV'`。
13
+ 4. 必须确认输出 `CONNECTED: 43in MAGNAVOX Roku TV`,并查询 Safari 当前视频的 `webkitCurrentPlaybackTargetIsWireless`;必要时让用户确认电视实际画面。
14
+ 5. 若失败,逐项检查 Safari 页面、视频播放状态、设备发现、macOS 辅助功能权限和 Roku AirPlay 设置。避免把屏幕镜像误当成视频直投。
15
+
16
+ ## CLI 工作机制和限制
17
+ - 通过 Safari AppleScript `do JavaScript` 调用 HTMLVideoElement `webkitShowPlaybackTargetPicker()`,再由 System Events 选择 AirPlay 目标。
18
+ - 对失败最多尝试 5 次;以 `webkitCurrentPlaybackTargetIsWireless` 为成功判据。
19
+ - 依赖 Safari UI 辅助功能树;UI 改版、播放器变更可能导致失效。
20
+ - `platforms/iyf/manifest.yaml` 仍标记 experimental,AirPlay 尚未注册为平台 action。
21
+ - Safari JavaScript 自动化需已允许;若不允许,按实际权限提示处理。
22
+
23
+ ## 后续测试
24
+ - 《沙丘2》影片 URL、冷启动多轮、断线重连、长时间播放均待验证。
25
+ - 测试时把退出 Safari、打开页面、调用 CLI 拆为独立命令;不要声称未执行的测试成功。
@@ -0,0 +1,18 @@
1
+ import json
2
+ from urllib.parse import urlparse
3
+
4
+ CFG = json.load(open("__CFG_PATH__"))
5
+ query = str(CFG.get("query", "")).strip()
6
+ if not query:
7
+ raise ValueError("config.query is required")
8
+ if not page_info()["url"].startswith("https://www.iyf.tv/"):
9
+ goto("https://www.iyf.tv/")
10
+ matches = js("""(() => {
11
+ const q = %s;
12
+ return Array.from(document.querySelectorAll('a[href*="/play/"]'))
13
+ .filter(a => a.textContent.includes(q))
14
+ .map(a => ({title: a.textContent.trim().slice(0,120), url:a.href}))
15
+ .filter((a,i,all) => all.findIndex(b => b.url === a.url) === i)
16
+ .slice(0,15)
17
+ })()""" % json.dumps(query, ensure_ascii=False))
18
+ print(json.dumps({"query": query, "matches": matches}, ensure_ascii=False))
@@ -0,0 +1,10 @@
1
+ import json
2
+ CFG = json.load(open("__CFG_PATH__"))
3
+ url = CFG.get("url","")
4
+ if not url.startswith("https://www.iyf.tv/play/"):
5
+ raise ValueError("Only IYF desktop play URLs are allowed")
6
+ new_tab(url)
7
+ wait_for_load()
8
+ info=page_info()
9
+ player = js("!!document.querySelector('video')")
10
+ print(json.dumps({"url":info["url"],"title":info["title"],"player":player},ensure_ascii=False))
@@ -0,0 +1,11 @@
1
+ platform: iyf
2
+ status: experimental
3
+ display_name: 爱壹帆
4
+ url_patterns:
5
+ - https://www.iyf.tv/*
6
+ actions:
7
+ find: actions/_find.py
8
+ open: actions/_open.py
9
+ verification:
10
+ status: experimental
11
+ evidence: find verified with Spider-Man title. Chrome page verified. AirPlay action not implemented.
@@ -0,0 +1,57 @@
1
+ #!/bin/zsh
2
+ # Select an AirPlay target for the currently playing Safari HTML video.
3
+ # Usage: safari-airplay [exact device name]
4
+ set -euo pipefail
5
+ TARGET="${1:-43in MAGNAVOX Roku TV}"
6
+ for attempt in 1 2 3 4 5; do
7
+ if result=$(/usr/bin/osascript - "$TARGET" 2>&1 <<'APPLESCRIPT'
8
+ on run argv
9
+ set targetName to item 1 of argv
10
+ tell application "Safari"
11
+ if (count of windows) = 0 then error "Safari has no open windows"
12
+ activate
13
+ set resultText to do JavaScript "(()=>{let v=[...document.querySelectorAll('video')].find(x=>!x.paused)||document.querySelector('video');if(!v)return 'NO_VIDEO';if(typeof v.webkitShowPlaybackTargetPicker!=='function')return 'NO_AIRPLAY';v.webkitShowPlaybackTargetPicker();return 'PICKER_REQUESTED'})()" in current tab of front window
14
+ if resultText is not "PICKER_REQUESTED" then error resultText
15
+ end tell
16
+ tell application "System Events"
17
+ tell process "Safari"
18
+ set matched to false
19
+ repeat 35 times
20
+ try
21
+ set deviceRows to rows of table 1 of scroll area 1 of pop over 1 of window 1
22
+ repeat with deviceRow in deviceRows
23
+ try
24
+ set itemName to value of static text 1 of UI element 1 of deviceRow
25
+ if itemName is targetName then
26
+ set selectedBox to checkbox 1 of UI element 1 of deviceRow
27
+ if (value of selectedBox as integer) is not 1 then click selectedBox
28
+ set matched to true
29
+ exit repeat
30
+ end if
31
+ end try
32
+ end repeat
33
+ if matched then exit repeat
34
+ end try
35
+ delay 0.2
36
+ end repeat
37
+ if not matched then error "AirPlay target not found: " & targetName
38
+ end tell
39
+ end tell
40
+ repeat 20 times
41
+ delay 0.5
42
+ tell application "Safari"
43
+ set wireless to do JavaScript "String([...document.querySelectorAll('video')].some(v=>!v.paused&&v.webkitCurrentPlaybackTargetIsWireless))" in current tab of front window
44
+ end tell
45
+ if wireless is "true" then return "CONNECTED: " & targetName
46
+ end repeat
47
+ error "Selected target but wireless playback not confirmed: " & targetName
48
+ end run
49
+ APPLESCRIPT
50
+ ); then
51
+ print -r -- "$result"
52
+ exit 0
53
+ fi
54
+ print -u2 -- "AirPlay attempt $attempt failed: $result"
55
+ sleep 2
56
+ done
57
+ exit 1
package/pyproject.toml CHANGED
@@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "browser-workspace"
7
- version = "0.4.0"
7
+ version = "0.4.9"
8
8
  description = "Agent browser workspace with persistent Python sessions, tab leasing, snapshots, and platform workflows."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
11
+ "browser-harness==0.1.13",
11
12
  "cdp-use==1.4.5",
12
13
  "fetch-use==0.4.0",
13
14
  "pillow==12.3.0",
@@ -15,6 +16,9 @@ dependencies = [
15
16
  "websockets==15.0.1",
16
17
  ]
17
18
 
19
+ [project.scripts]
20
+ browser-workspace = "browser_workspace_app.cli:main"
21
+
18
22
  [tool.setuptools]
19
23
  package-dir = {"" = "src"}
20
24
 
@@ -22,7 +26,7 @@ package-dir = {"" = "src"}
22
26
  where = ["src"]
23
27
 
24
28
  [tool.setuptools.package-data]
25
- browser_harness = ["video-template.html"]
29
+ browser_workspace_app = ["**/*"]
26
30
 
27
31
  [tool.pytest.ini_options]
28
32
  pythonpath = ["src"]
package/session_client.py CHANGED
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  ROOT=Path(__file__).resolve().parent
7
7
  RUNTIME_DIR=Path.home()/'.config/browser-workspace/runtime'
8
8
  SOCKET_PATH=(Path(os.environ['BROWSER_WORKSPACE_SESSION_SOCKET']) if os.environ.get('BROWSER_WORKSPACE_TEST_MODE')=='1' and os.environ.get('BROWSER_WORKSPACE_SESSION_SOCKET') else RUNTIME_DIR/'session.sock')
9
+ HARNESS_RUNTIME_SPEC='browser-harness @ git+https://github.com/lalalic/browser-harness.git@80b74e116b9126f5159c84e5e05a750573e5f192'
9
10
 
10
11
  def package_version():
11
12
  try:
@@ -30,6 +31,13 @@ def runtime_source():
30
31
  destination=tmp/name
31
32
  if source.is_dir(): shutil.copytree(source,destination)
32
33
  else: shutil.copy2(source,destination)
34
+ # A published wheel installs directly under browser_workspace_app/ without
35
+ # a nested src/ tree. Reconstruct the build layout only in the runtime
36
+ # cache, never in the repository or published source tree.
37
+ if not (tmp/'src').exists():
38
+ package_root = tmp/'src'/'browser_workspace_app'
39
+ package_root.parent.mkdir(parents=True, exist_ok=True)
40
+ shutil.copytree(ROOT, package_root, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.DS_Store'))
33
41
  (tmp/'.ready').write_text(package_version())
34
42
  shutil.rmtree(target,ignore_errors=True)
35
43
  tmp.rename(target)
@@ -53,6 +61,7 @@ def ensure_runtime_python():
53
61
  if uv:
54
62
  subprocess.run([uv,'venv','--python','3.11',str(PYTHON.parent.parent)],check=True)
55
63
  subprocess.run([uv,'pip','install','--python',str(PYTHON),'-e',str(SOURCE)],check=True)
64
+ subprocess.run([uv,'pip','install','--python',str(PYTHON),'--reinstall',HARNESS_RUNTIME_SPEC],check=True)
56
65
  return PYTHON
57
66
  if sys.version_info < (3,11):
58
67
  raise RuntimeError(
@@ -61,6 +70,7 @@ def ensure_runtime_python():
61
70
  )
62
71
  subprocess.run([sys.executable,'-m','venv',str(PYTHON.parent.parent)],check=True)
63
72
  subprocess.run([str(PYTHON),'-m','pip','install','-e',str(SOURCE)],check=True)
73
+ subprocess.run([str(PYTHON),'-m','pip','install','--force-reinstall',HARNESS_RUNTIME_SPEC],check=True)
64
74
  return PYTHON
65
75
 
66
76
  def request(payload, ensure=True):
package/session_daemon.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- import ast, atexit, contextlib, io, json, os, secrets, socket, sys, threading, time, traceback
2
+ import ast, atexit, contextlib, io, json, os, secrets, signal, socket, sys, threading, time, traceback
3
3
  from pathlib import Path
4
4
 
5
5
  ROOT = Path(__file__).resolve().parent
@@ -14,10 +14,31 @@ os.environ.setdefault('BH_WORKSPACE_NAME', 'Harness')
14
14
 
15
15
  from managed_chrome import ensure_managed_chrome
16
16
  MANAGED_CHROME = ensure_managed_chrome()
17
- from browser_harness.admin import ensure_daemon
18
- ensure_daemon()
17
+ from browser_harness.admin import ensure_daemon, _process_start_time
18
+ from browser_harness import _ipc as harness_ipc
19
19
  from browser_harness import helpers as bh
20
20
 
21
+ HARNESS_OWNER_TOKEN = secrets.token_hex(24)
22
+ HARNESS_OWNER_PID = os.getpid()
23
+ HARNESS_OWNER_STARTED = _process_start_time(HARNESS_OWNER_PID)
24
+ HARNESS_OWNER_PIPE = None
25
+
26
+
27
+ def stop_owned_harness():
28
+ """Only a harness matching this daemon generation may stop."""
29
+ try:
30
+ conn, token = harness_ipc.connect(os.environ.get('BU_NAME', 'browser-workspace'), timeout=1)
31
+ with conn:
32
+ response = harness_ipc.request(conn, token, {'meta': 'owner_shutdown', 'owner_token': HARNESS_OWNER_TOKEN})
33
+ if isinstance(response, dict) and response.get('ok'):
34
+ # Allow the harness its graceful CDP/IPC cleanup before EOF of
35
+ # our owner pipe forces the crash-only exit path.
36
+ deadline = time.monotonic() + 2
37
+ while time.monotonic() < deadline and harness_ipc.ping(os.environ.get('BU_NAME', 'browser-workspace'), timeout=0.1):
38
+ time.sleep(0.05)
39
+ except (OSError, ValueError, TimeoutError):
40
+ pass
41
+
21
42
  DEFAULT_WORKSPACE = os.environ.get('BH_WORKSPACE_NAME','Harness')
22
43
  RUNTIME_DIR = Path.home()/'.config/browser-workspace/runtime'
23
44
  SOCKET_PATH = (Path(os.environ['BROWSER_WORKSPACE_SESSION_SOCKET']) if os.environ.get('BROWSER_WORKSPACE_TEST_MODE')=='1' and os.environ.get('BROWSER_WORKSPACE_SESSION_SOCKET') else RUNTIME_DIR/'session.sock')
@@ -26,6 +47,47 @@ SESSIONS = {}
26
47
  SESSIONS_LOCK = threading.Lock()
27
48
  BROWSER_LOCK = threading.RLock()
28
49
 
50
+ SESSION_RECONCILE_SECONDS = 30
51
+
52
+
53
+ def is_reclaimed(info):
54
+ url = info.get('url', '')
55
+ if not url.startswith('chrome-extension://'):
56
+ return False
57
+ try:
58
+ from urllib.parse import urlparse, parse_qs
59
+ parsed = urlparse(url)
60
+ return parsed.path == '/workspace.html' and parse_qs(parsed.query).get('role') == ['idle']
61
+ except ValueError:
62
+ return False
63
+
64
+
65
+ def reconcile_sessions():
66
+ """Drop records for completely released leases, never release those tabs again."""
67
+ with BROWSER_LOCK:
68
+ try:
69
+ targets = bh.cdp('Target.getTargets')['targetInfos']
70
+ except Exception:
71
+ return []
72
+ pages = {i['targetId']: i for i in targets if i.get('type') == 'page'}
73
+ removed = []
74
+ with SESSIONS_LOCK:
75
+ for sid, session in list(SESSIONS.items()):
76
+ if session.lock.locked():
77
+ continue
78
+ # A leased root may have opened child tabs: keep any live child.
79
+ if all(tid not in pages or is_reclaimed(pages[tid])
80
+ for tid in session.owned_target_ids):
81
+ del SESSIONS[sid]
82
+ removed.append(sid)
83
+ return removed
84
+
85
+
86
+ def reconciliation_loop():
87
+ while True:
88
+ time.sleep(SESSION_RECONCILE_SECONDS)
89
+ reconcile_sessions()
90
+
29
91
 
30
92
  def compilable_module_body(body):
31
93
  """Return a module body that remains valid after trailing-result extraction.
@@ -274,6 +336,12 @@ def cleanup():
274
336
  for sid in ids:
275
337
  try: stop_session(sid)
276
338
  except Exception: pass
339
+ stop_owned_harness()
340
+ # The write end is close-on-exec; its final close also detects SIGKILL
341
+ # and parent re-exec without relying on a recycled numeric PID.
342
+ if HARNESS_OWNER_PIPE is not None:
343
+ try: os.close(HARNESS_OWNER_PIPE)
344
+ except OSError: pass
277
345
  try: remove_owned_socket()
278
346
  except Exception: pass
279
347
  atexit.register(cleanup)
@@ -332,9 +400,29 @@ def main():
332
400
  fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
333
401
  except BlockingIOError:
334
402
  owner.close(); return
403
+ global HARNESS_OWNER_PIPE
404
+ read_fd, write_fd = os.pipe() if os.name == 'posix' else (None, None)
405
+ HARNESS_OWNER_PIPE = write_fd
406
+ try:
407
+ owner_env = {
408
+ 'BH_OWNER_PID': str(HARNESS_OWNER_PID),
409
+ 'BH_OWNER_STARTED': str(HARNESS_OWNER_STARTED or ''),
410
+ 'BH_OWNER_TOKEN': HARNESS_OWNER_TOKEN,
411
+ }
412
+ if read_fd is not None:
413
+ owner_env['BH_OWNER_PIPE_FD'] = str(read_fd)
414
+ ensure_daemon(env=owner_env)
415
+ finally:
416
+ if read_fd is not None:
417
+ os.close(read_fd)
418
+ def terminate(_signum, _frame):
419
+ raise SystemExit(0)
420
+ signal.signal(signal.SIGTERM, terminate)
421
+ signal.signal(signal.SIGINT, terminate)
335
422
  # Lock ownership is required before changing the shared socket path.
336
423
  SOCKET_PATH.unlink(missing_ok=True)
337
424
  srv=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); srv.bind(str(SOCKET_PATH)); os.chmod(SOCKET_PATH,0o600); SOCKET_IDENTITY=(SOCKET_PATH.stat().st_dev, SOCKET_PATH.stat().st_ino); srv.listen(32)
425
+ threading.Thread(target=reconciliation_loop, daemon=True).start()
338
426
  try:
339
427
  while True:
340
428
  conn,_=srv.accept(); threading.Thread(target=serve_conn,args=(conn,),daemon=True).start()
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browser-workspace
3
- Version: 0.4.0
3
+ Version: 0.4.9
4
4
  Summary: Agent browser workspace with persistent Python sessions, tab leasing, snapshots, and platform workflows.
5
5
  Requires-Python: >=3.11
6
+ Requires-Dist: browser-harness==0.1.13
6
7
  Requires-Dist: cdp-use==1.4.5
7
8
  Requires-Dist: fetch-use==0.4.0
8
9
  Requires-Dist: pillow==12.3.0
@@ -1,28 +1,104 @@
1
+ MANIFEST.in
1
2
  README.md
3
+ platform_runner.py
2
4
  pyproject.toml
3
- src/browser_harness/__init__.py
4
- src/browser_harness/_ipc.py
5
- src/browser_harness/admin.py
6
- src/browser_harness/auth.py
7
- src/browser_harness/daemon.py
8
- src/browser_harness/helpers.py
9
- src/browser_harness/macos.py
10
- src/browser_harness/mcp_cli.py
11
- src/browser_harness/paths.py
12
- src/browser_harness/recorder.py
13
- src/browser_harness/run.py
14
- src/browser_harness/telemetry.py
15
- src/browser_harness/video-template.html
16
- src/browser_harness/video.py
17
- src/browser_harness/video_render.py
5
+ setup.py
6
+ platforms/chatgpt/README.md
7
+ platforms/chatgpt/manifest.yaml
8
+ platforms/chatgpt/actions/_bootstrap_start.py
9
+ platforms/chatgpt/actions/_lifecycle_common.py
10
+ platforms/chatgpt/actions/_mcp_app.py
11
+ platforms/chatgpt/actions/_new_thread.py
12
+ platforms/chatgpt/actions/_new_turn.py
13
+ platforms/chatgpt/actions/_project_setup.py
14
+ platforms/chatgpt/actions/_readiness.py
15
+ platforms/chatgpt/actions/_submit_thread.py
16
+ platforms/chatgpt/actions/_temporary_submit.py
17
+ platforms/chatgpt/actions/_thread_turn.py
18
+ platforms/chatgpt/tests/test_contract.py
19
+ platforms/chrome-web-store/README.md
20
+ platforms/chrome-web-store/manifest.yaml
21
+ platforms/chrome-web-store/actions/_chrome_web_store.py
22
+ platforms/chrome-web-store/flows/check-status.md
23
+ platforms/chrome-web-store/flows/open-item.md
24
+ platforms/chrome-web-store/flows/submit-review.md
25
+ platforms/chrome-web-store/flows/update-listing.md
26
+ platforms/chrome-web-store/flows/upload-package.md
27
+ platforms/chrome-web-store/flows/verify-published.md
28
+ platforms/chrome-web-store/tests/test_contract.py
29
+ platforms/iyf/AIRPLAY.md
30
+ platforms/iyf/manifest.yaml
31
+ platforms/iyf/safari-airplay
32
+ platforms/iyf/actions/_find.py
33
+ platforms/iyf/actions/_open.py
34
+ platforms/microsoft-teams/README.md
35
+ platforms/microsoft-teams/manifest.yaml
36
+ platforms/microsoft-teams/actions/_meeting.py
37
+ platforms/microsoft-teams/flows/instant-meeting.md
38
+ platforms/microsoft-teams/tests/test_contract.py
39
+ platforms/tiktok/README.md
40
+ platforms/tiktok/manifest.yaml
41
+ platforms/tiktok/actions/_post.py
42
+ platforms/wechat-channels/README.md
43
+ platforms/wechat-channels/manifest.yaml
44
+ platforms/wechat-channels/actions/_common.py
45
+ platforms/wechat-channels/actions/_manage.py
46
+ platforms/wechat-channels/actions/_post.py
47
+ platforms/wechat-channels/tests/test_contract.py
48
+ platforms/xhs/README.md
49
+ platforms/xhs/manifest.yaml
50
+ platforms/xhs/actions/_manage.py
51
+ platforms/xhs/actions/_post.py
52
+ platforms/xhs/tests/test_contract.py
53
+ platforms/youtube/README.md
54
+ platforms/youtube/manifest.yaml
55
+ platforms/youtube/actions/_post.py
18
56
  src/browser_workspace.egg-info/PKG-INFO
19
57
  src/browser_workspace.egg-info/SOURCES.txt
20
58
  src/browser_workspace.egg-info/dependency_links.txt
59
+ src/browser_workspace.egg-info/entry_points.txt
21
60
  src/browser_workspace.egg-info/requires.txt
22
61
  src/browser_workspace.egg-info/top_level.txt
62
+ src/browser_workspace_app/__init__.py
63
+ src/browser_workspace_app/cli.py
64
+ src/browser_workspace_app/managed_chrome.py
65
+ src/browser_workspace_app/package.json
66
+ src/browser_workspace_app/pyproject.toml
67
+ src/browser_workspace_app/session_client.py
68
+ src/browser_workspace_app/session_daemon.py
69
+ src/browser_workspace_app/agent-workspace/agent_helpers.py
70
+ src/browser_workspace_app/extension/manifest.json
71
+ src/browser_workspace_app/extension/service-worker.mjs
72
+ src/browser_workspace_app/extension/workspace-manager.mjs
73
+ src/browser_workspace_app/extension/workspace-page.mjs
74
+ src/browser_workspace_app/extension/workspace.html
75
+ src/browser_workspace_app/interaction-skills/connection.md
76
+ src/browser_workspace_app/interaction-skills/cookies.md
77
+ src/browser_workspace_app/interaction-skills/cross-origin-iframes.md
78
+ src/browser_workspace_app/interaction-skills/dialogs.md
79
+ src/browser_workspace_app/interaction-skills/downloads.md
80
+ src/browser_workspace_app/interaction-skills/drag-and-drop.md
81
+ src/browser_workspace_app/interaction-skills/dropdowns.md
82
+ src/browser_workspace_app/interaction-skills/iframes.md
83
+ src/browser_workspace_app/interaction-skills/network-requests.md
84
+ src/browser_workspace_app/interaction-skills/print-as-pdf.md
85
+ src/browser_workspace_app/interaction-skills/profile-sync.md
86
+ src/browser_workspace_app/interaction-skills/screenshots.md
87
+ src/browser_workspace_app/interaction-skills/scrolling.md
88
+ src/browser_workspace_app/interaction-skills/shadow-dom.md
89
+ src/browser_workspace_app/interaction-skills/tabs.md
90
+ src/browser_workspace_app/interaction-skills/uploads.md
91
+ src/browser_workspace_app/interaction-skills/viewport.md
92
+ src/browser_workspace_app/licenses/browser-harness-MIT.txt
93
+ src/browser_workspace_app/node/admin.mjs
94
+ test/test_auth_cookie_audit.py
95
+ test/test_auth_keepalive_visit.py
96
+ test/test_harness_ownership.py
23
97
  test/test_managed_chrome.py
24
98
  test/test_platform_profile.py
25
99
  test/test_platform_runner.py
100
+ test/test_pypi_package.py
26
101
  test/test_session_cli.py
27
102
  test/test_session_execution.py
103
+ test/test_session_reconcile.py
28
104
  test/test_workspace_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ browser-workspace = browser_workspace_app.cli:main
@@ -1,3 +1,4 @@
1
+ browser-harness==0.1.13
1
2
  cdp-use==1.4.5
2
3
  fetch-use==0.4.0
3
4
  pillow==12.3.0
@@ -1 +1 @@
1
- browser_harness
1
+ browser_workspace_app
@@ -0,0 +1 @@
1
+ """Browser Workspace Python distribution."""