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.
- package/README.md +15 -1
- package/SKILL.md +22 -21
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/package.json +1 -1
- package/platform_runner.py +11 -2
- package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
- package/platforms/chatgpt/actions/_new_turn.py +1 -0
- package/platforms/chatgpt/actions/_readiness.py +29 -3
- package/platforms/chatgpt/actions/_submit_thread.py +20 -3
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/actions/_thread_turn.py +31 -2
- package/platforms/chatgpt/tests/test_contract.py +114 -1
- package/platforms/iyf/AIRPLAY.md +25 -0
- package/platforms/iyf/actions/_find.py +18 -0
- package/platforms/iyf/actions/_open.py +10 -0
- package/platforms/iyf/manifest.yaml +11 -0
- package/platforms/iyf/safari-airplay +57 -0
- package/pyproject.toml +6 -2
- package/session_client.py +10 -0
- package/session_daemon.py +91 -3
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +91 -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/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +117 -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
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
browser_workspace_app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Browser Workspace Python distribution."""
|