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,362 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
from _readiness import wait_until_stable, submission_receipt, prompt_text_matches
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
|
|
10
|
+
|
|
11
|
+
def _composer():
|
|
12
|
+
selector = js("""(() => {
|
|
13
|
+
const selectors = [
|
|
14
|
+
'#prompt-textarea',
|
|
15
|
+
'[contenteditable="true"][data-composer-markdown]',
|
|
16
|
+
'[contenteditable="true"][data-lexical-editor="true"]',
|
|
17
|
+
'textarea'
|
|
18
|
+
];
|
|
19
|
+
for (const selector of selectors) {
|
|
20
|
+
const e=document.querySelector(selector);
|
|
21
|
+
if (!e || ['pending-home-input','pending-conversation-input'].includes(e.id) || e.disabled) continue;
|
|
22
|
+
const r=e.getBoundingClientRect();
|
|
23
|
+
if (r.width>0 && r.height>0) return selector;
|
|
24
|
+
}
|
|
25
|
+
const visible=[...document.querySelectorAll('[contenteditable="true"]')].filter(e => {
|
|
26
|
+
if (['pending-home-input','pending-conversation-input'].includes(e.id) || e.disabled) return false;
|
|
27
|
+
const r=e.getBoundingClientRect();
|
|
28
|
+
return r.width>0 && r.height>0;
|
|
29
|
+
});
|
|
30
|
+
return visible.length === 1 ? '[contenteditable="true"]' : null;
|
|
31
|
+
})()""")
|
|
32
|
+
if not selector:
|
|
33
|
+
raise RuntimeError("ChatGPT composer was not observed")
|
|
34
|
+
return selector
|
|
35
|
+
|
|
36
|
+
def _composer_text(selector):
|
|
37
|
+
return js(f"""(() => {{
|
|
38
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
39
|
+
if (!e) return '';
|
|
40
|
+
if (e.getAttribute('contenteditable') !== 'true') return e.value || '';
|
|
41
|
+
const clone=e.cloneNode(true);
|
|
42
|
+
clone.querySelectorAll('[app-mention-name]').forEach(node => node.remove());
|
|
43
|
+
return (clone.innerText || clone.textContent || '').trim();
|
|
44
|
+
}})()""") or ""
|
|
45
|
+
|
|
46
|
+
def _clear_composer(selector):
|
|
47
|
+
editable=bool(js(f"""(() => {{
|
|
48
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
49
|
+
return !!e && e.getAttribute('contenteditable') === 'true';
|
|
50
|
+
}})()"""))
|
|
51
|
+
if not editable:
|
|
52
|
+
fill_input(selector, "", clear_first=True)
|
|
53
|
+
return
|
|
54
|
+
ok=js(f"""(() => {{
|
|
55
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
56
|
+
if (!e) return false;
|
|
57
|
+
e.focus();
|
|
58
|
+
const sel=window.getSelection();
|
|
59
|
+
const range=document.createRange();
|
|
60
|
+
range.selectNodeContents(e);
|
|
61
|
+
sel.removeAllRanges(); sel.addRange(range);
|
|
62
|
+
document.execCommand('delete', false, null);
|
|
63
|
+
sel.removeAllRanges();
|
|
64
|
+
return true;
|
|
65
|
+
}})()""")
|
|
66
|
+
if not ok:
|
|
67
|
+
raise RuntimeError("composer could not be cleared")
|
|
68
|
+
|
|
69
|
+
def _attach_app(selector, target_id, app_name, timeout=10):
|
|
70
|
+
name=str(app_name or "").strip()
|
|
71
|
+
if not name:
|
|
72
|
+
return False
|
|
73
|
+
_clear_composer(selector)
|
|
74
|
+
session=cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
|
|
75
|
+
cdp("Input.insertText", session_id=session, text="@")
|
|
76
|
+
wanted=name.lower()
|
|
77
|
+
deadline=time.time()+timeout
|
|
78
|
+
while time.time()<deadline:
|
|
79
|
+
clicked=js(f"""(() => {{
|
|
80
|
+
const wanted={json.dumps(wanted)};
|
|
81
|
+
const buttons=[...document.querySelectorAll('button,[role="option"],[role="menuitem"]')];
|
|
82
|
+
const candidate=buttons.find(e => {{
|
|
83
|
+
const text=(e.innerText||e.textContent||'').replace(/\s+/g,'').trim().toLowerCase();
|
|
84
|
+
return text===wanted || text===wanted+wanted;
|
|
85
|
+
}});
|
|
86
|
+
if(!candidate) return false;
|
|
87
|
+
candidate.click();
|
|
88
|
+
return true;
|
|
89
|
+
}})()""")
|
|
90
|
+
if clicked:
|
|
91
|
+
break
|
|
92
|
+
time.sleep(.1)
|
|
93
|
+
else:
|
|
94
|
+
raise RuntimeError(f"ChatGPT app '{name}' was not offered by the composer")
|
|
95
|
+
deadline=time.time()+timeout
|
|
96
|
+
while time.time()<deadline:
|
|
97
|
+
attached=js(f"""(() => {{
|
|
98
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
99
|
+
if(!e) return false;
|
|
100
|
+
return !![...e.querySelectorAll('[app-mention-name]')].find(
|
|
101
|
+
n => (n.getAttribute('app-mention-name')||'').toLowerCase()==={json.dumps(wanted)}
|
|
102
|
+
);
|
|
103
|
+
}})()""")
|
|
104
|
+
if attached:
|
|
105
|
+
return True
|
|
106
|
+
time.sleep(.1)
|
|
107
|
+
raise RuntimeError(f"ChatGPT app '{name}' mention did not attach")
|
|
108
|
+
|
|
109
|
+
def _attachment_state():
|
|
110
|
+
return js(r"""(() => {
|
|
111
|
+
const names=[...document.querySelectorAll('button[aria-label^="Remove "]')]
|
|
112
|
+
.map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, '').replace(/^Remove\s+/, ''))
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
const visible=e => { const r=e.getBoundingClientRect(); return r.width>0 && r.height>0; };
|
|
115
|
+
const pendingSelector='[role="progressbar"], [aria-busy="true"], [data-state="loading"]';
|
|
116
|
+
const pending=[...document.querySelectorAll(pendingSelector)].some(visible);
|
|
117
|
+
return {names, pending};
|
|
118
|
+
})()""") or {"names": [], "pending": False}
|
|
119
|
+
|
|
120
|
+
def _send_state():
|
|
121
|
+
return js(r"""(() => {
|
|
122
|
+
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
123
|
+
return {present:!!b, enabled:!!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true'};
|
|
124
|
+
})()""") or {"present": False, "enabled": False}
|
|
125
|
+
|
|
126
|
+
def _user_turns():
|
|
127
|
+
return js("""(() => {
|
|
128
|
+
const legacy=[...document.querySelectorAll('[data-message-author-role="user"]')]
|
|
129
|
+
.map(e => ({text:(e.innerText || '').trim(), id:e.getAttribute('data-message-id') || ''}))
|
|
130
|
+
.filter(x => x.text);
|
|
131
|
+
if (legacy.length) return legacy;
|
|
132
|
+
return [...document.querySelectorAll('h4')]
|
|
133
|
+
.filter(e => (e.innerText || '').trim() === 'You said:')
|
|
134
|
+
.map((e,index) => {
|
|
135
|
+
const raw=(e.parentElement?.innerText || '').trim();
|
|
136
|
+
return {text:raw.replace(/^You said:\\s*/,'').trim(), id:`dom-user-${index}`};
|
|
137
|
+
})
|
|
138
|
+
.filter(x => x.text);
|
|
139
|
+
})()""") or []
|
|
140
|
+
|
|
141
|
+
def _assistant_messages():
|
|
142
|
+
return js("""(() => {
|
|
143
|
+
const legacy=[...document.querySelectorAll('[data-message-author-role="assistant"]')]
|
|
144
|
+
.map(e => ({text:(e.innerText || '').trim(), id:e.getAttribute('data-message-id') || ''}))
|
|
145
|
+
.filter(x => x.text);
|
|
146
|
+
if (legacy.length) return legacy;
|
|
147
|
+
return [...document.querySelectorAll('h4')]
|
|
148
|
+
.filter(e => (e.innerText || '').trim() === 'ChatGPT said:')
|
|
149
|
+
.map((e,index) => {
|
|
150
|
+
const raw=(e.parentElement?.innerText || '').trim();
|
|
151
|
+
return {text:raw.replace(/^ChatGPT said:\\s*/,'').trim(), id:`dom-assistant-${index}`};
|
|
152
|
+
})
|
|
153
|
+
.filter(x => x.text);
|
|
154
|
+
})()""") or []
|
|
155
|
+
|
|
156
|
+
def _is_generating():
|
|
157
|
+
return bool(js("""(() => !![...document.querySelectorAll('button')].find(b => {
|
|
158
|
+
const a=(b.getAttribute('aria-label')||'').toLowerCase();
|
|
159
|
+
const t=(b.textContent||'').trim().toLowerCase();
|
|
160
|
+
const id=(b.getAttribute('data-testid')||'').toLowerCase();
|
|
161
|
+
return a.includes('stop answering') || a.includes('stop generating') || t === 'stop' || id.includes('stop');
|
|
162
|
+
}))()"""))
|
|
163
|
+
|
|
164
|
+
def _fill_prompt(prompt, timeout=30, preserve_app=False):
|
|
165
|
+
deadline=time.time()+timeout
|
|
166
|
+
last_error=None
|
|
167
|
+
while time.time()<deadline:
|
|
168
|
+
selector=_composer()
|
|
169
|
+
if prompt_text_matches(_composer_text(selector), prompt):
|
|
170
|
+
return selector
|
|
171
|
+
editable=bool(js(f"""(() => {{
|
|
172
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
173
|
+
return !!e && e.getAttribute('contenteditable') === 'true';
|
|
174
|
+
}})()"""))
|
|
175
|
+
try:
|
|
176
|
+
if editable:
|
|
177
|
+
if not preserve_app:
|
|
178
|
+
_clear_composer(selector)
|
|
179
|
+
else:
|
|
180
|
+
ok=js(f"""(() => {{
|
|
181
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
182
|
+
if (!e) return false;
|
|
183
|
+
e.focus();
|
|
184
|
+
const sel=window.getSelection();
|
|
185
|
+
const range=document.createRange();
|
|
186
|
+
range.selectNodeContents(e);
|
|
187
|
+
range.collapse(false);
|
|
188
|
+
sel.removeAllRanges(); sel.addRange(range);
|
|
189
|
+
return true;
|
|
190
|
+
}})()""")
|
|
191
|
+
if not ok: raise RuntimeError("composer caret could not be placed after app mention")
|
|
192
|
+
for offset in range(0, len(prompt), 256):
|
|
193
|
+
chunk=prompt[offset:offset+256]
|
|
194
|
+
inserted=js(f"""(() => {{
|
|
195
|
+
const e=document.querySelector({json.dumps(selector)});
|
|
196
|
+
if (!e) return false;
|
|
197
|
+
e.focus();
|
|
198
|
+
const ok=document.execCommand('insertText', false, {json.dumps(chunk)});
|
|
199
|
+
e.dispatchEvent(new InputEvent('input', {{bubbles:true,inputType:'insertText',data:{json.dumps(chunk)}}}));
|
|
200
|
+
return ok;
|
|
201
|
+
}})()""")
|
|
202
|
+
if not inserted: raise RuntimeError("composer rejected prompt chunk")
|
|
203
|
+
else:
|
|
204
|
+
if preserve_app:
|
|
205
|
+
raise RuntimeError("ChatGPT app mention requires a contenteditable composer")
|
|
206
|
+
fill_input(selector, prompt, clear_first=True)
|
|
207
|
+
return selector
|
|
208
|
+
except RuntimeError as exc:
|
|
209
|
+
last_error=exc
|
|
210
|
+
time.sleep(.25)
|
|
211
|
+
raise RuntimeError(f"ChatGPT composer could not be filled: {last_error}")
|
|
212
|
+
|
|
213
|
+
def _upload_files(paths):
|
|
214
|
+
if not paths: return []
|
|
215
|
+
selector=js("""(() => {
|
|
216
|
+
for (const s of ['#upload-files','#upload-media','input[name="upload-media"]','input[type="file"]'])
|
|
217
|
+
if (document.querySelector(s)) return s;
|
|
218
|
+
return null;
|
|
219
|
+
})()""")
|
|
220
|
+
if not selector:
|
|
221
|
+
opened=js("""(() => {
|
|
222
|
+
const b=[...document.querySelectorAll('button')].find(b => (b.getAttribute('aria-label')||'').trim()==='Add files and more');
|
|
223
|
+
if (!b) return false; b.click(); return true;
|
|
224
|
+
})()""")
|
|
225
|
+
if opened:
|
|
226
|
+
deadline=time.time()+5
|
|
227
|
+
while time.time()<deadline and not selector:
|
|
228
|
+
selector=js("""(() => {
|
|
229
|
+
for (const s of ['#upload-files','#upload-media','input[name="upload-media"]','input[type="file"]'])
|
|
230
|
+
if (document.querySelector(s)) return s;
|
|
231
|
+
return null;
|
|
232
|
+
})()""")
|
|
233
|
+
if not selector: time.sleep(.1)
|
|
234
|
+
if not selector: raise RuntimeError("ChatGPT file input was not observed")
|
|
235
|
+
expected=[]
|
|
236
|
+
for path in paths:
|
|
237
|
+
if not os.path.isfile(path): raise RuntimeError(f"attachment does not exist: {path}")
|
|
238
|
+
upload_file(selector, path)
|
|
239
|
+
expected.append(os.path.basename(path))
|
|
240
|
+
wait_until_stable(
|
|
241
|
+
_attachment_state,
|
|
242
|
+
lambda state: set(expected).issubset(state["names"]) and not state["pending"],
|
|
243
|
+
timeout=120,
|
|
244
|
+
phase="attachment readiness",
|
|
245
|
+
)
|
|
246
|
+
return expected
|
|
247
|
+
|
|
248
|
+
def _submit(prompt, files, target_id):
|
|
249
|
+
selector=_composer()
|
|
250
|
+
app_name=CFG.get("app")
|
|
251
|
+
app_attached=_attach_app(selector, target_id, app_name) if app_name else False
|
|
252
|
+
selector=_fill_prompt(prompt, preserve_app=app_attached)
|
|
253
|
+
attachments=_upload_files(files)
|
|
254
|
+
before_users=len(_user_turns())
|
|
255
|
+
before_assistants=len(_assistant_messages())
|
|
256
|
+
state=wait_until_stable(
|
|
257
|
+
lambda: {"selector":_composer(),"text":_composer_text(_composer()),"attachments":_attachment_state(),"send":_send_state()},
|
|
258
|
+
lambda s: prompt_text_matches(s["text"], prompt) and set(attachments).issubset(s["attachments"]["names"]) and not s["attachments"]["pending"] and s["send"]["enabled"],
|
|
259
|
+
timeout=45 if attachments else 20,
|
|
260
|
+
phase="send readiness",
|
|
261
|
+
)
|
|
262
|
+
selector=state["selector"]
|
|
263
|
+
clicked=js("""(() => {
|
|
264
|
+
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
265
|
+
if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
|
|
266
|
+
b.click(); return true;
|
|
267
|
+
})()""")
|
|
268
|
+
if not clicked: raise RuntimeError("ChatGPT send button changed before click")
|
|
269
|
+
deadline=time.time()+30
|
|
270
|
+
receipt=None
|
|
271
|
+
while time.time()<deadline:
|
|
272
|
+
receipt=submission_receipt(_user_turns(), before_users, prompt, _composer_text(selector))
|
|
273
|
+
if receipt: break
|
|
274
|
+
time.sleep(.25)
|
|
275
|
+
if not receipt: raise RuntimeError("ChatGPT submission verification did not observe an accepted submission")
|
|
276
|
+
return before_assistants, attachments, receipt
|
|
277
|
+
|
|
278
|
+
def _result(before_assistants, timeout):
|
|
279
|
+
deadline=time.time()+timeout
|
|
280
|
+
signature=None
|
|
281
|
+
stable_since=None
|
|
282
|
+
while time.time()<deadline:
|
|
283
|
+
if _is_generating():
|
|
284
|
+
signature=None
|
|
285
|
+
stable_since=None
|
|
286
|
+
time.sleep(.5)
|
|
287
|
+
continue
|
|
288
|
+
messages=_assistant_messages()
|
|
289
|
+
if len(messages)<=before_assistants:
|
|
290
|
+
time.sleep(.5)
|
|
291
|
+
continue
|
|
292
|
+
latest=messages[-1]
|
|
293
|
+
sig=(latest.get("id"), latest.get("text"))
|
|
294
|
+
if sig!=signature:
|
|
295
|
+
signature=sig
|
|
296
|
+
stable_since=time.time()
|
|
297
|
+
time.sleep(.5)
|
|
298
|
+
continue
|
|
299
|
+
if not latest.get("text") or stable_since is None or time.time()-stable_since<3:
|
|
300
|
+
time.sleep(.5)
|
|
301
|
+
continue
|
|
302
|
+
return latest
|
|
303
|
+
raise RuntimeError("ChatGPT assistant result did not become complete before timeout")
|
|
304
|
+
|
|
305
|
+
def _open_thread():
|
|
306
|
+
thread_url=CFG["thread_url"]
|
|
307
|
+
expected_path=urlsplit(thread_url).path.rstrip("/")
|
|
308
|
+
target_id=CFG.get("target_id")
|
|
309
|
+
recovered=False
|
|
310
|
+
if target_id:
|
|
311
|
+
try:
|
|
312
|
+
switch_tab(target_id)
|
|
313
|
+
current_path=urlsplit(page_info().get("url","")).path.rstrip("/")
|
|
314
|
+
if current_path!=expected_path:
|
|
315
|
+
# If the bound target still exists but was navigated elsewhere,
|
|
316
|
+
# recover it in place rather than opening another browser target.
|
|
317
|
+
goto_url(thread_url)
|
|
318
|
+
wait_for_load()
|
|
319
|
+
recovered=True
|
|
320
|
+
deadline=time.time()+30
|
|
321
|
+
while time.time()<deadline:
|
|
322
|
+
if urlsplit(page_info().get("url","")).path.rstrip("/")==expected_path:
|
|
323
|
+
break
|
|
324
|
+
time.sleep(.25)
|
|
325
|
+
else:
|
|
326
|
+
raise RuntimeError("bound Tutor target could not recover its thread")
|
|
327
|
+
except Exception:
|
|
328
|
+
target_id=None
|
|
329
|
+
recovered=True
|
|
330
|
+
if not target_id:
|
|
331
|
+
target_id=new_tab(thread_url)
|
|
332
|
+
wait_for_load()
|
|
333
|
+
deadline=time.time()+30
|
|
334
|
+
while time.time()<deadline:
|
|
335
|
+
if urlsplit(page_info().get("url","")).path.rstrip("/")==expected_path:
|
|
336
|
+
break
|
|
337
|
+
time.sleep(.25)
|
|
338
|
+
else:
|
|
339
|
+
raise RuntimeError("Existing ChatGPT thread redirect mismatch")
|
|
340
|
+
wait_until_stable(
|
|
341
|
+
lambda: {"selector": _composer()},
|
|
342
|
+
lambda state: bool(state["selector"]),
|
|
343
|
+
timeout=30,
|
|
344
|
+
phase="thread hydration",
|
|
345
|
+
)
|
|
346
|
+
return target_id, recovered
|
|
347
|
+
|
|
348
|
+
target_id, recovered=_open_thread()
|
|
349
|
+
before_assistants, attachments, receipt=_submit(CFG["prompt"], CFG.get("file", []), target_id)
|
|
350
|
+
assistant=_result(before_assistants, CFG.get("result_timeout", 240))
|
|
351
|
+
print(json.dumps({
|
|
352
|
+
"status":"completed",
|
|
353
|
+
"thread_url":page_info().get("url","").split("?",1)[0],
|
|
354
|
+
"target_id":target_id,
|
|
355
|
+
"recovered":recovered,
|
|
356
|
+
"attachments":attachments,
|
|
357
|
+
"app":CFG.get("app") or None,
|
|
358
|
+
"verified_by":receipt["verified_by"],
|
|
359
|
+
"user_message_id":receipt["turn"].get("id") or None,
|
|
360
|
+
"assistant_message_id":assistant.get("id") or None,
|
|
361
|
+
"text":assistant.get("text",""),
|
|
362
|
+
}, ensure_ascii=False), flush=True)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
platform: chatgpt
|
|
2
|
+
status: migrated_unverified
|
|
3
|
+
last_verified: null
|
|
4
|
+
actions:
|
|
5
|
+
submit-thread: "actions/_submit_thread.py"
|
|
6
|
+
temporary-submit: "actions/_temporary_submit.py"
|
|
7
|
+
thread-turn: "actions/_thread_turn.py"
|
|
8
|
+
new-turn: "actions/_new_turn.py"
|
|
9
|
+
new-thread: "actions/_new_thread.py"
|
|
10
|
+
project-setup: "actions/_project_setup.py"
|
|
11
|
+
bootstrap-start: "actions/_bootstrap_start.py"
|
|
12
|
+
mcp-app: "actions/_mcp_app.py"
|
|
13
|
+
verification:
|
|
14
|
+
status: migrated_unverified
|
|
15
|
+
last_verified: null
|
|
16
|
+
evidence: "The source ChatGPT worker flow was previously live-verified, but the migrated Browser Workspace platform action has not yet been live-verified through the current generic platform runner."
|
|
17
|
+
flows:
|
|
18
|
+
- disposable-worker-submit
|
|
19
|
+
- existing-thread-turn
|
|
20
|
+
- lifecycle-new-turn
|
|
21
|
+
- lifecycle-new-thread
|
|
22
|
+
- project-setup
|
|
23
|
+
- mcp-app-management
|
|
24
|
+
capabilities:
|
|
25
|
+
- persistent-thread
|
|
26
|
+
- attachments
|
|
27
|
+
- submit-verification
|
|
28
|
+
- assistant-completion
|
|
29
|
+
- project-bootstrap
|
|
30
|
+
- project-instructions
|
|
31
|
+
- project-memory
|
|
32
|
+
- mcp-app-inspection
|
|
33
|
+
- mcp-tool-refresh
|
|
34
|
+
- lifecycle-control-transfer
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
4
|
+
ACTIONS = ROOT / "actions"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_platform_files_exist():
|
|
8
|
+
assert (ROOT / "manifest.yaml").is_file()
|
|
9
|
+
assert (ACTIONS / "_thread_turn.py").is_file()
|
|
10
|
+
assert (ACTIONS / "_new_turn.py").is_file()
|
|
11
|
+
assert (ACTIONS / "_new_thread.py").is_file()
|
|
12
|
+
assert (ACTIONS / "_lifecycle_common.py").is_file()
|
|
13
|
+
assert (ACTIONS / "_submit_thread.py").is_file()
|
|
14
|
+
assert (ACTIONS / "_project_setup.py").is_file()
|
|
15
|
+
assert (ACTIONS / "_bootstrap_start.py").is_file()
|
|
16
|
+
assert (ACTIONS / "_mcp_app.py").is_file()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_manifest_declares_actions_and_verification():
|
|
20
|
+
text = (ROOT / "manifest.yaml").read_text()
|
|
21
|
+
assert 'thread-turn: "actions/_thread_turn.py"' in text
|
|
22
|
+
assert 'new-turn: "actions/_new_turn.py"' in text
|
|
23
|
+
assert 'new-thread: "actions/_new_thread.py"' in text
|
|
24
|
+
assert 'submit-thread: "actions/_submit_thread.py"' in text
|
|
25
|
+
assert "verification:" in text
|
|
26
|
+
assert "status: migrated_unverified" in text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_action_uses_browser_helpers_without_infrastructure_import():
|
|
30
|
+
text = (ACTIONS / "_thread_turn.py").read_text()
|
|
31
|
+
assert "from browser_harness" not in text
|
|
32
|
+
assert "session_client" not in text
|
|
33
|
+
assert "workspace_set_" not in text
|
|
34
|
+
assert "switch_tab(target_id)" in text
|
|
35
|
+
assert "new_tab(thread_url)" in text
|
|
36
|
+
assert '"recovered":recovered' in text
|
|
37
|
+
assert "from _readiness import wait_until_stable, submission_receipt" in text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_project_setup_supports_required_inputs():
|
|
41
|
+
text = (ACTIONS / "_project_setup.py").read_text()
|
|
42
|
+
assert "def _ensure_project" in text
|
|
43
|
+
assert "project_reused" in text
|
|
44
|
+
assert "project-only" in text
|
|
45
|
+
assert "def _send_initial" in text
|
|
46
|
+
assert '"thread_url":thread_url' in text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_thread_turn_supports_app_attachment():
|
|
50
|
+
text = (ACTIONS / "_thread_turn.py").read_text()
|
|
51
|
+
assert "def _attach_app" in text
|
|
52
|
+
assert "app-mention-name" in text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_thread_recovery_reuses_bound_target_before_new_target():
|
|
56
|
+
driver = (ACTIONS / "_thread_turn.py").read_text()
|
|
57
|
+
open_thread = driver.split("def _open_thread():",1)[1].split("target_id, recovered=_open_thread()",1)[0]
|
|
58
|
+
assert "goto_url(thread_url)" in open_thread
|
|
59
|
+
assert "target_id=new_tab(thread_url)" in open_thread
|
|
60
|
+
assert open_thread.index("goto_url(thread_url)") < open_thread.index("target_id=new_tab(thread_url)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_prompt_match_tolerates_block_tag_boundary_whitespace():
|
|
64
|
+
ns = {}
|
|
65
|
+
exec((ACTIONS / "_readiness.py").read_text(), ns)
|
|
66
|
+
expected = '<FAMILY_TUTOR_CONTEXT>\n{"type":"kid","data":{"message":"hello world"}}\n</FAMILY_TUTOR_CONTEXT>'
|
|
67
|
+
observed = '<FAMILY_TUTOR_CONTEXT>{"type":"kid","data":{"message":"hello world"}}</FAMILY_TUTOR_CONTEXT>'
|
|
68
|
+
assert ns["prompt_text_matches"](observed, expected)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_wait_until_stable_retries_transient_read_errors():
|
|
72
|
+
ns = {}
|
|
73
|
+
exec((ACTIONS / "_readiness.py").read_text(), ns)
|
|
74
|
+
calls = {"n": 0}
|
|
75
|
+
def read_state():
|
|
76
|
+
calls["n"] += 1
|
|
77
|
+
if calls["n"] < 3:
|
|
78
|
+
raise RuntimeError("not hydrated")
|
|
79
|
+
return {"ready": True}
|
|
80
|
+
result = ns["wait_until_stable"](
|
|
81
|
+
read_state,
|
|
82
|
+
lambda state: state.get("ready") is True,
|
|
83
|
+
timeout=0.2,
|
|
84
|
+
interval=0.001,
|
|
85
|
+
stable_samples=1,
|
|
86
|
+
phase="test",
|
|
87
|
+
)
|
|
88
|
+
assert result == {"ready": True}
|
|
89
|
+
assert calls["n"] == 3
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_submit_thread_is_submission_only():
|
|
93
|
+
text=(ACTIONS/"_submit_thread.py").read_text()
|
|
94
|
+
assert 'project_id' in text and 'thread_id' in text
|
|
95
|
+
assert '?prompt=' in text
|
|
96
|
+
assert 'submission_receipt' in text
|
|
97
|
+
assert 'response_complete' not in text
|
|
98
|
+
assert "app_name" in text and "attach_app_and_restore_prompt" in text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_manifest_declares_mcp_app_action():
|
|
102
|
+
text = (ROOT / "manifest.yaml").read_text()
|
|
103
|
+
assert 'mcp-app: "actions/_mcp_app.py"' in text
|
|
104
|
+
assert "mcp-app-management" in text
|
|
105
|
+
assert "mcp-tool-refresh" in text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_mcp_app_action_uses_semantic_selectors_and_safe_operations():
|
|
109
|
+
text = (ACTIONS / "_mcp_app.py").read_text()
|
|
110
|
+
assert 'Open profile menu' not in text # direct settings route avoids profile-menu fragility
|
|
111
|
+
assert 'https://chatgpt.com/settings/plugins-settings' in text
|
|
112
|
+
assert "APP_NAME" in text
|
|
113
|
+
assert "text.startsWith(name+" in text
|
|
114
|
+
assert "body.includes('Disconnect')" in text
|
|
115
|
+
assert "body.includes('Permissions')" in text
|
|
116
|
+
assert "texts.includes('Refresh tools')" in text
|
|
117
|
+
assert 'operation must be status, open, or refresh-tools' in text
|
|
118
|
+
assert 'Delete app' not in text
|
|
119
|
+
assert 'Uninstall' in text # observed only; never clicked
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_mcp_app_readme_captures_snapshot_first_setup_flow():
|
|
123
|
+
text = (ROOT / "README.md").read_text()
|
|
124
|
+
assert "Configure a custom MCP app" in text
|
|
125
|
+
assert "snapshot(interactive_only=False)" in text
|
|
126
|
+
assert "Permissions" in text and "Disconnect" in text and "Refresh tools" in text
|
|
127
|
+
assert "never commit credentials" in text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_lifecycle_actions_use_stable_ids_and_do_not_wait_for_new_answer():
|
|
131
|
+
turn=(ACTIONS/"_new_turn.py").read_text()
|
|
132
|
+
thread=(ACTIONS/"_new_thread.py").read_text()
|
|
133
|
+
common=(ACTIONS/"_lifecycle_common.py").read_text()
|
|
134
|
+
assert "project_id" in turn and "thread_id" in turn
|
|
135
|
+
assert "wait_for_idle" in turn and "submit(message)" in turn
|
|
136
|
+
assert "project_id" in thread and "source_thread_id" in thread
|
|
137
|
+
assert "local-chatgpt" in thread and "thread_id" in thread
|
|
138
|
+
assert "assistant" not in turn.lower().split("print(json.dumps",1)[-1]
|
|
139
|
+
assert "assistant" not in thread.lower().split("print(json.dumps",1)[-1]
|
|
140
|
+
assert "session start" not in turn and "session stop" not in turn
|
|
141
|
+
assert "session start" not in thread and "session stop" not in thread
|
|
142
|
+
assert "submission_receipt" in common
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_new_thread_supports_projectless_and_temporary_modes():
|
|
146
|
+
thread=(ACTIONS/"_new_thread.py").read_text()
|
|
147
|
+
assert 'temporary=bool(CFG.get("temporary", False))' in thread
|
|
148
|
+
assert 'temporary=true cannot be combined with project_id' in thread
|
|
149
|
+
assert 'https://chatgpt.com/?temporary-chat=true' in thread
|
|
150
|
+
assert 'start_url="https://chatgpt.com/"' in thread
|
|
151
|
+
assert 'Temporary Chat mode was not activated' in thread
|
|
152
|
+
assert '"temporary":True' in thread
|
|
153
|
+
assert '"thread_id":None' in thread
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_lifecycle_actions_bind_session_helpers_into_imported_module():
|
|
157
|
+
for name in ("_new_turn.py", "_new_thread.py"):
|
|
158
|
+
text=(ACTIONS/name).read_text()
|
|
159
|
+
assert "import _lifecycle_common as lifecycle_common" in text
|
|
160
|
+
assert "lifecycle_common.js = js" in text
|
|
161
|
+
assert "lifecycle_common.fill_input = fill_input" in text
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_temporary_new_thread_finishes_without_system_exit():
|
|
165
|
+
thread=(ACTIONS/"_new_thread.py").read_text()
|
|
166
|
+
assert "SystemExit" not in thread
|
|
167
|
+
assert "if temporary:" in thread
|
|
168
|
+
assert "else:" in thread
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_new_turn_project_id_is_optional():
|
|
172
|
+
turn=(ACTIONS/"_new_turn.py").read_text()
|
|
173
|
+
assert 'project_id=str(CFG.get("project_id") or "").strip() or None' in turn
|
|
174
|
+
assert 'if not thread_id: raise RuntimeError("thread_id is required")' in turn
|
|
175
|
+
assert 'https://chatgpt.com/c/{quote(thread_id' in turn
|
|
176
|
+
assert 'https://chatgpt.com/g/{quote(project_id' in turn
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chrome-web-store-platform
|
|
3
|
+
description: "Chrome Web Store platform flows for Browser Workspace platform bundle."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Chrome Web Store
|
|
7
|
+
|
|
8
|
+
Use the parent Browser Workspace skill contract first.
|
|
9
|
+
These assets document the verified platform contract and use the
|
|
10
|
+
platform action as the reusable interaction boundary. Live actions still
|
|
11
|
+
require explicit authorization and report observed evidence instead of
|
|
12
|
+
inferring success.
|
|
13
|
+
|
|
14
|
+
Use only the publisher account and listing explicitly selected by the caller.
|
|
15
|
+
Publishing and listing updates have external side effects and require an
|
|
16
|
+
explicit user request.
|
|
17
|
+
|
|
18
|
+
## Runner
|
|
19
|
+
|
|
20
|
+
Invoke `actions/_chrome_web_store.py` through `Browser Workspace` with
|
|
21
|
+
an approved JSON configuration. The supported actions are:
|
|
22
|
+
|
|
23
|
+
`resolve-item`, `create-item`, `open-item`, `upload-package`, `update-listing`, `verify-draft`, `save-draft`, `submit-review`,
|
|
24
|
+
`check-status`, and `verify-published`.
|
|
25
|
+
|
|
26
|
+
The runner is dry-run by default. `upload-package`, `update-listing`, and
|
|
27
|
+
`submit-review` require `commit: true`; `submit-review` additionally requires
|
|
28
|
+
the caller to explicitly opt in with `allow_external_submit: true`. This keeps
|
|
29
|
+
Release Agent responsible for authorization while this skill owns browser
|
|
30
|
+
mechanics. Every action emits a JSON evidence record containing the observed
|
|
31
|
+
URL, item id, action, and status (`draft`, `submitted`, `pending`, `rejected`,
|
|
32
|
+
`published`, or `unknown`).
|
|
33
|
+
|
|
34
|
+
`create-item` supports the no-existing-item path: it validates the explicit
|
|
35
|
+
package, opens the publisher-scoped dashboard, creates the item only with
|
|
36
|
+
`commit: true`, and returns the observed editor item id. `resolve-item` reuses a
|
|
37
|
+
matching product item and dry-runs creation when none exists. `verify-draft`
|
|
38
|
+
reopens the exact item and proves the dashboard version; `save-draft` is the
|
|
39
|
+
reusable no-field-change regression action. Passing a prior item id always
|
|
40
|
+
reopens/reuses it, so retries do not create duplicates.
|
|
41
|
+
|
|
42
|
+
The input `market_package` is product-owned content supplied by Market Agent;
|
|
43
|
+
this skill validates presence and maps supplied values to store fields but does
|
|
44
|
+
not invent copy or media. It accepts title, short/long description, release
|
|
45
|
+
notes, reviewer test instructions, support/privacy URLs, privacy/data-use
|
|
46
|
+
declarations, distribution/payment designation, permissions and host
|
|
47
|
+
justifications, screenshots, posters/feature graphics, and demo video.
|
|
48
|
+
`package_path` and `expected_version` are required for package upload; the ZIP's
|
|
49
|
+
`manifest.json` version must match before browser interaction begins.
|