browser-workspace 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +21 -0
- package/README.md +138 -0
- package/SKILL.md +301 -0
- package/agent-workspace/agent_helpers.py +718 -0
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +75 -0
- package/extension/manifest.json +26 -0
- package/extension/service-worker.mjs +126 -0
- package/extension/workspace-manager.mjs +680 -0
- package/extension/workspace-page.mjs +7 -0
- package/extension/workspace.html +8 -0
- package/interaction-skills/connection.md +52 -0
- package/interaction-skills/cookies.md +3 -0
- package/interaction-skills/cross-origin-iframes.md +3 -0
- package/interaction-skills/dialogs.md +64 -0
- package/interaction-skills/downloads.md +3 -0
- package/interaction-skills/drag-and-drop.md +3 -0
- package/interaction-skills/dropdowns.md +3 -0
- package/interaction-skills/iframes.md +3 -0
- package/interaction-skills/network-requests.md +3 -0
- package/interaction-skills/print-as-pdf.md +3 -0
- package/interaction-skills/profile-sync.md +90 -0
- package/interaction-skills/screenshots.md +17 -0
- package/interaction-skills/scrolling.md +22 -0
- package/interaction-skills/shadow-dom.md +3 -0
- package/interaction-skills/tabs.md +73 -0
- package/interaction-skills/uploads.md +1 -0
- package/interaction-skills/viewport.md +16 -0
- package/licenses/browser-harness-MIT.txt +21 -0
- package/managed_chrome.py +244 -0
- package/node/admin.mjs +44 -0
- package/package.json +36 -0
- package/platform_runner.py +187 -0
- package/platforms/chatgpt/README.md +60 -0
- package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/platforms/chatgpt/actions/_readiness.py +52 -0
- package/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
- package/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/platforms/chatgpt/manifest.yaml +34 -0
- package/platforms/chatgpt/tests/test_contract.py +176 -0
- package/platforms/chrome-web-store/README.md +49 -0
- package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/platforms/chrome-web-store/manifest.yaml +33 -0
- package/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/platforms/microsoft-teams/README.md +32 -0
- package/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/platforms/microsoft-teams/manifest.yaml +25 -0
- package/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/platforms/tiktok/README.md +10 -0
- package/platforms/tiktok/actions/_post.py +180 -0
- package/platforms/tiktok/manifest.yaml +99 -0
- package/platforms/wechat-channels/README.md +10 -0
- package/platforms/wechat-channels/actions/_common.py +163 -0
- package/platforms/wechat-channels/actions/_manage.py +130 -0
- package/platforms/wechat-channels/actions/_post.py +232 -0
- package/platforms/wechat-channels/manifest.yaml +118 -0
- package/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/platforms/xhs/README.md +10 -0
- package/platforms/xhs/actions/_manage.py +355 -0
- package/platforms/xhs/actions/_post.py +192 -0
- package/platforms/xhs/manifest.yaml +98 -0
- package/platforms/youtube/README.md +10 -0
- package/platforms/youtube/actions/_post.py +100 -0
- package/platforms/youtube/manifest.yaml +94 -0
- package/pyproject.toml +29 -0
- package/session_client.py +97 -0
- package/session_daemon.py +325 -0
- package/src/browser_harness/__init__.py +2 -0
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +201 -0
- package/src/browser_harness/admin.py +1588 -0
- package/src/browser_harness/auth.py +546 -0
- package/src/browser_harness/daemon.py +894 -0
- package/src/browser_harness/helpers.py +668 -0
- package/src/browser_harness/macos.py +131 -0
- package/src/browser_harness/mcp_cli.py +16 -0
- package/src/browser_harness/paths.py +49 -0
- package/src/browser_harness/recorder.py +343 -0
- package/src/browser_harness/run.py +413 -0
- package/src/browser_harness/telemetry.py +308 -0
- package/src/browser_harness/video-template.html +970 -0
- package/src/browser_harness/video.py +749 -0
- package/src/browser_harness/video_render.py +524 -0
- package/src/browser_workspace.egg-info/PKG-INFO +10 -0
- package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
- package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
- package/src/browser_workspace.egg-info/requires.txt +5 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
# Canonical one-shot ChatGPT Temporary Chat submission mechanics.
|
|
2
|
+
# Lifecycle/barrier ownership belongs to the caller (Agents Relay).
|
|
3
|
+
"""Bounded polling helpers for ChatGPT submit readiness."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def wait_until_stable(
|
|
12
|
+
read_state: Callable[[], Mapping[str, object]],
|
|
13
|
+
ready: Callable[[Mapping[str, object]], bool],
|
|
14
|
+
*,
|
|
15
|
+
timeout: float,
|
|
16
|
+
interval: float = 0.25,
|
|
17
|
+
stable_samples: int = 2,
|
|
18
|
+
phase: str,
|
|
19
|
+
) -> Mapping[str, object]:
|
|
20
|
+
"""Return only after the observable state is ready for stable_samples polls."""
|
|
21
|
+
deadline = time.monotonic() + timeout
|
|
22
|
+
stable = 0
|
|
23
|
+
last_state: Mapping[str, object] = {}
|
|
24
|
+
while time.monotonic() < deadline:
|
|
25
|
+
last_state = read_state()
|
|
26
|
+
if ready(last_state):
|
|
27
|
+
stable += 1
|
|
28
|
+
if stable >= stable_samples:
|
|
29
|
+
return last_state
|
|
30
|
+
else:
|
|
31
|
+
stable = 0
|
|
32
|
+
time.sleep(interval)
|
|
33
|
+
raise RuntimeError(f"Temporary Chat {phase} was not observed ready")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def actionable_temporary_chat_candidates(candidates):
|
|
37
|
+
return [
|
|
38
|
+
candidate for candidate in candidates
|
|
39
|
+
if candidate.get("visible")
|
|
40
|
+
and not candidate.get("disabled")
|
|
41
|
+
and candidate.get("pointerEvents") != "none"
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def temporary_chat_entry_url(prompt=""):
|
|
46
|
+
"""Open Temporary Chat and let ChatGPT prefill the prompt from the URL."""
|
|
47
|
+
query = {"temporary-chat": "true"}
|
|
48
|
+
if prompt:
|
|
49
|
+
query["prompt"] = prompt
|
|
50
|
+
return "https://chatgpt.com/?" + urlencode(query)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def temporary_chat_enabled_state(url, candidates):
|
|
55
|
+
"""Recognize Temporary Chat from stable URL state with semantic UI fallback."""
|
|
56
|
+
if "temporary-chat=true" in (url or ""):
|
|
57
|
+
return True
|
|
58
|
+
return any(
|
|
59
|
+
"turn off" in f"{candidate.get('label') or ''} {candidate.get('text') or ''}".lower()
|
|
60
|
+
for candidate in candidates
|
|
61
|
+
if candidate.get("visible")
|
|
62
|
+
and not candidate.get("disabled")
|
|
63
|
+
and candidate.get("pointerEvents") != "none"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def prompt_text_matches(observed, expected):
|
|
68
|
+
"""Verify long composer/user-turn text despite ProseMirror whitespace reshaping."""
|
|
69
|
+
import re
|
|
70
|
+
normalize = lambda value: re.sub(r"\s+", " ", value or "").strip()
|
|
71
|
+
observed_n = normalize(observed)
|
|
72
|
+
expected_n = normalize(expected)
|
|
73
|
+
if expected_n in observed_n:
|
|
74
|
+
return True
|
|
75
|
+
if not expected_n:
|
|
76
|
+
return not observed_n
|
|
77
|
+
span = min(256, max(48, len(expected_n) // 8))
|
|
78
|
+
return (
|
|
79
|
+
len(observed_n) >= int(len(expected_n) * 0.9)
|
|
80
|
+
and expected_n[:span] in observed_n
|
|
81
|
+
and expected_n[-span:] in observed_n
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def submission_receipt(turns, before_count, prompt, composer_text):
|
|
86
|
+
"""Return a transport receipt after click without requiring one DOM message shape."""
|
|
87
|
+
for turn in turns[before_count:]:
|
|
88
|
+
if prompt_text_matches(turn.get("text"), prompt):
|
|
89
|
+
return {"verified_by": "user-turn", "turn": turn}
|
|
90
|
+
if not (composer_text or "").strip():
|
|
91
|
+
return {
|
|
92
|
+
"verified_by": "composer-cleared",
|
|
93
|
+
"turn": {"id": None, "text": prompt},
|
|
94
|
+
}
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
import atexit
|
|
99
|
+
import json
|
|
100
|
+
import os
|
|
101
|
+
import re
|
|
102
|
+
import time
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
|
|
107
|
+
_OWNED_TABS = []
|
|
108
|
+
_KEEP_OWNED_TAB_OPEN = True
|
|
109
|
+
_SUBMISSION_SUCCEEDED = False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _new_owned_tab(url):
|
|
114
|
+
# Respect Browser Harness workspace adapters. Record both target + workspace
|
|
115
|
+
# tab id so cleanup remains scoped to exactly this worker's own lease even
|
|
116
|
+
# if ChatGPT navigation invalidates the original target mapping.
|
|
117
|
+
target_id = new_tab(url)
|
|
118
|
+
tabs = [tab for tab in list_tabs(include_chrome=False) if tab.get("targetId") == target_id]
|
|
119
|
+
if len(tabs) != 1:
|
|
120
|
+
raise RuntimeError(f"Owned Browser Workspace target {target_id!r} was not mapped to one tab")
|
|
121
|
+
_OWNED_TABS.append({
|
|
122
|
+
"targetId": target_id,
|
|
123
|
+
"tabId": tabs[0].get("tabId"),
|
|
124
|
+
})
|
|
125
|
+
return target_id
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _close_owned_tabs():
|
|
129
|
+
# close-policy=never preserves an owned tab only after a verified submit.
|
|
130
|
+
# Any failure before that point must release this worker's own lease.
|
|
131
|
+
if _KEEP_OWNED_TAB_OPEN and _SUBMISSION_SUCCEEDED:
|
|
132
|
+
return
|
|
133
|
+
while _OWNED_TABS:
|
|
134
|
+
owned = _OWNED_TABS.pop()
|
|
135
|
+
try:
|
|
136
|
+
close_tab(owned["targetId"])
|
|
137
|
+
continue
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
current = current_tab()
|
|
142
|
+
if owned.get("tabId") is not None and current.get("tabId") == owned["tabId"]:
|
|
143
|
+
close_tab()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
atexit.register(_close_owned_tabs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _composer():
|
|
152
|
+
selector = js("""(() => {
|
|
153
|
+
const selectors = [
|
|
154
|
+
'#prompt-textarea',
|
|
155
|
+
'[contenteditable="true"][data-composer-markdown]',
|
|
156
|
+
'[contenteditable="true"][data-lexical-editor="true"]',
|
|
157
|
+
'textarea'
|
|
158
|
+
];
|
|
159
|
+
for (const selector of selectors) {
|
|
160
|
+
const e=document.querySelector(selector);
|
|
161
|
+
if (!e || e.id === 'pending-home-input' || e.disabled) continue;
|
|
162
|
+
const r=e.getBoundingClientRect();
|
|
163
|
+
if (r.width>0 && r.height>0) return selector;
|
|
164
|
+
}
|
|
165
|
+
const visible = [...document.querySelectorAll('[contenteditable="true"]')]
|
|
166
|
+
.filter(e => {
|
|
167
|
+
if (e.id === 'pending-home-input' || e.disabled) return false;
|
|
168
|
+
const r=e.getBoundingClientRect();
|
|
169
|
+
return r.width>0 && r.height>0;
|
|
170
|
+
});
|
|
171
|
+
return visible.length === 1 ? '[contenteditable="true"]' : null;
|
|
172
|
+
})()""")
|
|
173
|
+
if not selector:
|
|
174
|
+
raise RuntimeError("ChatGPT composer was not observed")
|
|
175
|
+
return selector
|
|
176
|
+
|
|
177
|
+
def _composer_text(selector):
|
|
178
|
+
return js(f"""(() => {{
|
|
179
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
180
|
+
if (!e) return '';
|
|
181
|
+
return e.getAttribute('contenteditable') === 'true'
|
|
182
|
+
? (e.innerText || '')
|
|
183
|
+
: (e.value || '');
|
|
184
|
+
}})()""") or ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _wait_for_composer(timeout=30):
|
|
188
|
+
def read_selector():
|
|
189
|
+
try:
|
|
190
|
+
return _composer()
|
|
191
|
+
except RuntimeError:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
return wait_until_stable(
|
|
195
|
+
lambda: {"selector": read_selector()},
|
|
196
|
+
lambda state: bool(state["selector"]),
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
phase="composer readiness",
|
|
199
|
+
)["selector"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _same_text(observed, expected):
|
|
203
|
+
normalize = lambda value: re.sub(r"\\s+", " ", value or "").strip()
|
|
204
|
+
return normalize(expected) in normalize(observed)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _normalized_text(value):
|
|
208
|
+
return re.sub(r"\s+", " ", value or "").strip()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _temporary_chat_candidates():
|
|
212
|
+
return js(r"""(() => [...document.querySelectorAll('button')].map((b, index) => {
|
|
213
|
+
const label=(b.getAttribute('aria-label') || '').trim();
|
|
214
|
+
const text=(b.innerText || '').trim();
|
|
215
|
+
const semantic=/temporary/i.test(label + ' ' + text) && /chat/i.test(label + ' ' + text);
|
|
216
|
+
if (!semantic) return null;
|
|
217
|
+
const r=b.getBoundingClientRect();
|
|
218
|
+
const style=getComputedStyle(b);
|
|
219
|
+
return {
|
|
220
|
+
index,
|
|
221
|
+
label,
|
|
222
|
+
text,
|
|
223
|
+
visible:r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none',
|
|
224
|
+
disabled:!!b.disabled || b.getAttribute('aria-disabled') === 'true',
|
|
225
|
+
pointerEvents:style.pointerEvents,
|
|
226
|
+
};
|
|
227
|
+
}).filter(Boolean))()""") or []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _temporary_chat_enabled():
|
|
231
|
+
return temporary_chat_enabled_state(
|
|
232
|
+
page_info().get("url", ""),
|
|
233
|
+
_temporary_chat_candidates(),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _click_temporary_chat_toggle(timeout=20):
|
|
238
|
+
deadline = time.time() + timeout
|
|
239
|
+
last_candidates = []
|
|
240
|
+
while time.time() < deadline:
|
|
241
|
+
if _temporary_chat_enabled():
|
|
242
|
+
return "already-enabled"
|
|
243
|
+
last_candidates = _temporary_chat_candidates()
|
|
244
|
+
actionable = actionable_temporary_chat_candidates(last_candidates)
|
|
245
|
+
if len(actionable) == 1:
|
|
246
|
+
clicked = js(f"""(() => {{
|
|
247
|
+
const b=[...document.querySelectorAll('button')][{int(actionable[0]["index"])}];
|
|
248
|
+
if (!b) return false;
|
|
249
|
+
const r=b.getBoundingClientRect();
|
|
250
|
+
const style=getComputedStyle(b);
|
|
251
|
+
if (!(r.width > 0 && r.height > 0) || b.disabled ||
|
|
252
|
+
b.getAttribute('aria-disabled') === 'true' || style.pointerEvents === 'none') return false;
|
|
253
|
+
b.click();
|
|
254
|
+
return true;
|
|
255
|
+
}})()""")
|
|
256
|
+
if clicked:
|
|
257
|
+
return "clicked"
|
|
258
|
+
elif len(actionable) > 1:
|
|
259
|
+
labels = [candidate.get("label") or candidate.get("text") or "<unnamed>" for candidate in actionable]
|
|
260
|
+
raise RuntimeError(f"Temporary Chat toggle is ambiguous: {len(actionable)} actionable controls {labels}")
|
|
261
|
+
time.sleep(.25)
|
|
262
|
+
raise RuntimeError(
|
|
263
|
+
"Temporary Chat toggle was not actionable after "
|
|
264
|
+
f"{timeout}s; semantic candidates={last_candidates}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _attachment_names():
|
|
269
|
+
return js(r"""(() => [...document.querySelectorAll('button[aria-label^="Remove file"]')]
|
|
270
|
+
.map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, ''))
|
|
271
|
+
.filter(Boolean))()""") or []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _attachment_state():
|
|
275
|
+
return js(r"""(() => {
|
|
276
|
+
const names = [...document.querySelectorAll('button[aria-label^="Remove "]')]
|
|
277
|
+
.map(b => (b.getAttribute('aria-label') || '')
|
|
278
|
+
.replace(/^Remove file\s+\d+:\s*/, '')
|
|
279
|
+
.replace(/^Remove\s+/, ''))
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
return {names, pending: false};
|
|
282
|
+
})()""") or {"names": [], "pending": False}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _send_state():
|
|
286
|
+
return js(r"""(() => {
|
|
287
|
+
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
288
|
+
return {present: !!b, enabled: !!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true'};
|
|
289
|
+
})()""") or {"present": False, "enabled": False}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _wait_for_attachments(expected, timeout=45):
|
|
293
|
+
expected = set(expected)
|
|
294
|
+
wait_until_stable(
|
|
295
|
+
_attachment_state,
|
|
296
|
+
lambda state: expected.issubset(state["names"]) and not state["pending"],
|
|
297
|
+
timeout=timeout,
|
|
298
|
+
phase="attachment upload readiness",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _upload_files(paths):
|
|
303
|
+
if not paths:
|
|
304
|
+
return []
|
|
305
|
+
|
|
306
|
+
def file_input_selector():
|
|
307
|
+
return js("""(() => {
|
|
308
|
+
const selectors = [
|
|
309
|
+
'#upload-files',
|
|
310
|
+
'#upload-media',
|
|
311
|
+
'input[name="upload-media"]',
|
|
312
|
+
'input[type="file"]:not([accept])',
|
|
313
|
+
'input[type="file"]'
|
|
314
|
+
];
|
|
315
|
+
for (const s of selectors) if (document.querySelector(s)) return s;
|
|
316
|
+
return null;
|
|
317
|
+
})()""")
|
|
318
|
+
|
|
319
|
+
selector = file_input_selector()
|
|
320
|
+
if not selector:
|
|
321
|
+
opened = js("""(() => {
|
|
322
|
+
const b=[...document.querySelectorAll('button')].find(b =>
|
|
323
|
+
(b.getAttribute('aria-label') || '').trim() === 'Add files and more');
|
|
324
|
+
if (!b) return false;
|
|
325
|
+
b.click();
|
|
326
|
+
return true;
|
|
327
|
+
})()""")
|
|
328
|
+
if opened:
|
|
329
|
+
deadline = time.time() + 5
|
|
330
|
+
while time.time() < deadline and not selector:
|
|
331
|
+
selector = file_input_selector()
|
|
332
|
+
if not selector:
|
|
333
|
+
time.sleep(.1)
|
|
334
|
+
if not selector:
|
|
335
|
+
raise RuntimeError("ChatGPT file input was not observed")
|
|
336
|
+
for path in paths:
|
|
337
|
+
if not os.path.isfile(path):
|
|
338
|
+
raise RuntimeError(f"attachment does not exist: {path}")
|
|
339
|
+
upload_file(selector, path)
|
|
340
|
+
expected = [os.path.basename(path) for path in paths]
|
|
341
|
+
if expected:
|
|
342
|
+
# Presence is a selection signal; upload readiness is checked separately.
|
|
343
|
+
wait_until_stable(
|
|
344
|
+
_attachment_state,
|
|
345
|
+
lambda state: set(expected).issubset(state["names"]),
|
|
346
|
+
timeout=30,
|
|
347
|
+
phase="attachment presence",
|
|
348
|
+
)
|
|
349
|
+
_wait_for_attachments(expected)
|
|
350
|
+
return expected
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _user_turns():
|
|
354
|
+
return js("""(() => [...document.querySelectorAll('[data-message-author-role="user"]')]
|
|
355
|
+
.map(e => ({text:e.innerText.trim(), id:e.getAttribute('data-message-id') || ''}))
|
|
356
|
+
.filter(e => e.text))()""") or []
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _click_send():
|
|
360
|
+
ok = js("""(() => {
|
|
361
|
+
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
362
|
+
if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
|
|
363
|
+
b.click(); return true;
|
|
364
|
+
})()""")
|
|
365
|
+
if not ok:
|
|
366
|
+
raise RuntimeError("Temporary Chat send readiness changed before click")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _wait_for_send_ready(selector, prompt, attachments):
|
|
370
|
+
expected = set(attachments)
|
|
371
|
+
|
|
372
|
+
def read_state():
|
|
373
|
+
try:
|
|
374
|
+
live_selector = _composer()
|
|
375
|
+
except RuntimeError:
|
|
376
|
+
live_selector = selector
|
|
377
|
+
return {
|
|
378
|
+
"selector": live_selector,
|
|
379
|
+
"text": _composer_text(live_selector),
|
|
380
|
+
"attachments": _attachment_state(),
|
|
381
|
+
"send": _send_state(),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return wait_until_stable(
|
|
385
|
+
read_state,
|
|
386
|
+
lambda state: (
|
|
387
|
+
prompt_text_matches(state["text"], prompt)
|
|
388
|
+
and expected.issubset(state["attachments"]["names"])
|
|
389
|
+
and state["send"]["enabled"]
|
|
390
|
+
),
|
|
391
|
+
timeout=45 if expected else 15,
|
|
392
|
+
phase="send readiness",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _wait_user_turn(before_count, prompt, selector, timeout=20):
|
|
397
|
+
deadline = time.time() + timeout
|
|
398
|
+
cleared_polls = 0
|
|
399
|
+
last_error = None
|
|
400
|
+
while time.time() < deadline:
|
|
401
|
+
try:
|
|
402
|
+
receipt = submission_receipt(
|
|
403
|
+
_user_turns(),
|
|
404
|
+
before_count,
|
|
405
|
+
prompt,
|
|
406
|
+
_composer_text(selector),
|
|
407
|
+
)
|
|
408
|
+
last_error = None
|
|
409
|
+
except RuntimeError as exc:
|
|
410
|
+
# The Browser Harness/CDP daemon can transiently time out while
|
|
411
|
+
# ChatGPT is committing a navigation/message update. Receipt
|
|
412
|
+
# observation is read-only, so retry within the existing bound.
|
|
413
|
+
last_error = exc
|
|
414
|
+
time.sleep(.25)
|
|
415
|
+
continue
|
|
416
|
+
if receipt:
|
|
417
|
+
if receipt["verified_by"] == "user-turn":
|
|
418
|
+
return receipt
|
|
419
|
+
cleared_polls += 1
|
|
420
|
+
if cleared_polls >= 2:
|
|
421
|
+
return receipt
|
|
422
|
+
else:
|
|
423
|
+
cleared_polls = 0
|
|
424
|
+
time.sleep(.25)
|
|
425
|
+
suffix = f"; last observation error: {last_error}" if last_error else ""
|
|
426
|
+
raise RuntimeError(
|
|
427
|
+
"ChatGPT submission verification did not observe an accepted submission" + suffix
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _diagnostic_thread_id():
|
|
432
|
+
match = re.search(r"/c/([^/?#]+)", page_info().get("url", ""))
|
|
433
|
+
return match.group(1) if match else None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _fill_prompt_if_needed(prompt, mode_label, timeout=30):
|
|
439
|
+
deadline = time.time() + timeout
|
|
440
|
+
last_error = None
|
|
441
|
+
while time.time() < deadline:
|
|
442
|
+
selector = _wait_for_composer(timeout=min(5, max(1, deadline - time.time())))
|
|
443
|
+
if prompt_text_matches(_composer_text(selector), prompt):
|
|
444
|
+
return selector
|
|
445
|
+
|
|
446
|
+
is_contenteditable = bool(js(f"""(() => {{
|
|
447
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
448
|
+
return !!e && e.getAttribute('contenteditable') === 'true';
|
|
449
|
+
}})()"""))
|
|
450
|
+
try:
|
|
451
|
+
if is_contenteditable:
|
|
452
|
+
cleared = js(f"""(() => {{
|
|
453
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
454
|
+
if (!e) return false;
|
|
455
|
+
e.focus();
|
|
456
|
+
const sel=window.getSelection();
|
|
457
|
+
const range=document.createRange();
|
|
458
|
+
range.selectNodeContents(e);
|
|
459
|
+
sel.removeAllRanges();
|
|
460
|
+
sel.addRange(range);
|
|
461
|
+
document.execCommand('delete', false, null);
|
|
462
|
+
return true;
|
|
463
|
+
}})()""")
|
|
464
|
+
if not cleared:
|
|
465
|
+
raise RuntimeError(
|
|
466
|
+
f"{mode_label} contenteditable composer could not be cleared"
|
|
467
|
+
)
|
|
468
|
+
for offset in range(0, len(prompt), 256):
|
|
469
|
+
chunk = prompt[offset:offset + 256]
|
|
470
|
+
inserted = js(f"""(() => {{
|
|
471
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
472
|
+
if (!e) return false;
|
|
473
|
+
e.focus();
|
|
474
|
+
const ok=document.execCommand(
|
|
475
|
+
'insertText', false, {json.dumps(chunk)}
|
|
476
|
+
);
|
|
477
|
+
e.dispatchEvent(new InputEvent('input', {{
|
|
478
|
+
bubbles:true,
|
|
479
|
+
inputType:'insertText',
|
|
480
|
+
data:{json.dumps(chunk)}
|
|
481
|
+
}}));
|
|
482
|
+
return ok;
|
|
483
|
+
}})()""")
|
|
484
|
+
if not inserted:
|
|
485
|
+
raise RuntimeError(
|
|
486
|
+
f"{mode_label} composer rejected prompt chunk at offset {offset}"
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
fill_input(selector, prompt, clear_first=True)
|
|
490
|
+
return selector
|
|
491
|
+
except RuntimeError as exc:
|
|
492
|
+
# ChatGPT can replace textarea with ProseMirror during hydration.
|
|
493
|
+
# Re-resolve the live composer instead of keeping a stale selector.
|
|
494
|
+
last_error = exc
|
|
495
|
+
time.sleep(.25)
|
|
496
|
+
|
|
497
|
+
suffix = f"; last fill error: {last_error}" if last_error else ""
|
|
498
|
+
raise RuntimeError(f"{mode_label} composer could not be filled{suffix}")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _live_composer_state():
|
|
502
|
+
try:
|
|
503
|
+
selector = _composer()
|
|
504
|
+
except RuntimeError:
|
|
505
|
+
return {"selector": None, "text": ""}
|
|
506
|
+
return {"selector": selector, "text": _composer_text(selector)}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _submit_current_page(prompt, *, temporary, mode_label):
|
|
510
|
+
_fill_prompt_if_needed(prompt, mode_label)
|
|
511
|
+
composer_state = wait_until_stable(
|
|
512
|
+
_live_composer_state,
|
|
513
|
+
lambda state: (
|
|
514
|
+
bool(state["selector"])
|
|
515
|
+
and prompt_text_matches(state["text"], prompt)
|
|
516
|
+
),
|
|
517
|
+
timeout=30,
|
|
518
|
+
phase=f"{mode_label} composer readiness",
|
|
519
|
+
)
|
|
520
|
+
selector = composer_state["selector"]
|
|
521
|
+
|
|
522
|
+
attachments = _upload_files(CFG.get("file", []))
|
|
523
|
+
before_count = len(_user_turns())
|
|
524
|
+
|
|
525
|
+
send_state = _wait_for_send_ready(selector, prompt, attachments)
|
|
526
|
+
if isinstance(send_state, dict) and send_state.get("selector"):
|
|
527
|
+
selector = send_state["selector"]
|
|
528
|
+
|
|
529
|
+
_click_send()
|
|
530
|
+
receipt = _wait_user_turn(before_count, prompt, selector)
|
|
531
|
+
global _SUBMISSION_SUCCEEDED
|
|
532
|
+
_SUBMISSION_SUCCEEDED = True
|
|
533
|
+
owned = _OWNED_TABS[-1] if _OWNED_TABS else {}
|
|
534
|
+
return {
|
|
535
|
+
"operation": "submit",
|
|
536
|
+
"status": "submitted",
|
|
537
|
+
"temporary": temporary,
|
|
538
|
+
"attachments": attachments,
|
|
539
|
+
"diagnostic_thread_id": _diagnostic_thread_id(),
|
|
540
|
+
"user_message_id": receipt["turn"].get("id") or None,
|
|
541
|
+
"owned_tab_id": owned.get("tabId"),
|
|
542
|
+
"owned_target_id": owned.get("targetId"),
|
|
543
|
+
"verified_by": receipt["verified_by"],
|
|
544
|
+
"verified": True,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
def _wait_for_release():
|
|
548
|
+
release_file = CFG.get("release_file")
|
|
549
|
+
if not release_file:
|
|
550
|
+
raise RuntimeError("worker release file was not configured")
|
|
551
|
+
if CFG.get("close_policy", "after-start") == "never":
|
|
552
|
+
raise SystemExit(0)
|
|
553
|
+
while not os.path.exists(release_file):
|
|
554
|
+
time.sleep(.1)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
prompt = CFG["prompt"]
|
|
558
|
+
_new_owned_tab(temporary_chat_entry_url(prompt))
|
|
559
|
+
wait_for_load()
|
|
560
|
+
|
|
561
|
+
_click_temporary_chat_toggle()
|
|
562
|
+
deadline = time.time() + 20
|
|
563
|
+
while time.time() < deadline:
|
|
564
|
+
if _temporary_chat_enabled():
|
|
565
|
+
break
|
|
566
|
+
time.sleep(.25)
|
|
567
|
+
else:
|
|
568
|
+
raise RuntimeError("Temporary Chat activation was not observed")
|
|
569
|
+
|
|
570
|
+
result = _submit_current_page(prompt, temporary=True, mode_label="Temporary Chat")
|
|
571
|
+
print(json.dumps(result, ensure_ascii=False), flush=True)
|