browser-workspace 0.4.1 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +16 -2
  2. package/SKILL.md +60 -37
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/bin/browser-workspace +174 -13
  5. package/package.json +1 -1
  6. package/platform_runner.py +23 -6
  7. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  8. package/platforms/chatgpt/tests/test_contract.py +7 -0
  9. package/platforms/wechat-channels/actions/_common.py +1 -1
  10. package/platforms/wechat-channels/actions/_post.py +37 -38
  11. package/platforms/wechat-channels/manifest.yaml +19 -13
  12. package/platforms/xhs/actions/_post.py +109 -20
  13. package/platforms/xhs/manifest.yaml +14 -5
  14. package/platforms/xhs/tests/test_contract.py +17 -0
  15. package/platforms/youtube/actions/_post.py +7 -4
  16. package/pyproject.toml +6 -2
  17. package/session_client.py +21 -11
  18. package/session_daemon.py +115 -9
  19. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  20. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  21. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  22. package/src/browser_workspace.egg-info/requires.txt +1 -0
  23. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  24. package/src/browser_workspace_app/__init__.py +1 -0
  25. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  26. package/src/browser_workspace_app/cli.py +236 -0
  27. package/src/browser_workspace_app/extension/manifest.json +26 -0
  28. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  29. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  30. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  31. package/src/browser_workspace_app/extension/workspace.html +8 -0
  32. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  33. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  34. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  35. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  36. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  37. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  38. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  39. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  40. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  41. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  42. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  43. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  44. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  45. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  46. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  47. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  48. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  49. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  50. package/src/browser_workspace_app/managed_chrome.py +244 -0
  51. package/src/browser_workspace_app/node/admin.mjs +44 -0
  52. package/src/browser_workspace_app/package.json +36 -0
  53. package/src/browser_workspace_app/platform_runner.py +204 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  57. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  58. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  59. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  60. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  61. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  62. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  63. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  64. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  65. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  66. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  67. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  68. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  69. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  70. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  71. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  72. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  73. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  74. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  75. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  76. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  77. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  78. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  79. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  80. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  81. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  82. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  83. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  84. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  85. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  86. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  87. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  88. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  89. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  90. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  91. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  92. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  93. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  94. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  95. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  96. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  97. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  98. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  99. package/src/browser_workspace_app/pyproject.toml +33 -0
  100. package/src/browser_workspace_app/session_client.py +107 -0
  101. package/src/browser_workspace_app/session_daemon.py +431 -0
  102. package/src/browser_harness/__init__.py +0 -2
  103. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  104. package/src/browser_harness/_ipc.py +0 -201
  105. package/src/browser_harness/admin.py +0 -1588
  106. package/src/browser_harness/auth.py +0 -546
  107. package/src/browser_harness/daemon.py +0 -894
  108. package/src/browser_harness/helpers.py +0 -668
  109. package/src/browser_harness/macos.py +0 -131
  110. package/src/browser_harness/mcp_cli.py +0 -16
  111. package/src/browser_harness/paths.py +0 -49
  112. package/src/browser_harness/recorder.py +0 -343
  113. package/src/browser_harness/run.py +0 -413
  114. package/src/browser_harness/telemetry.py +0 -308
  115. package/src/browser_harness/video-template.html +0 -970
  116. package/src/browser_harness/video.py +0 -749
  117. package/src/browser_harness/video_render.py +0 -524
@@ -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,183 @@
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
177
+
178
+
179
+ def test_temporary_submit_recovers_only_before_send_from_prefill_hydration():
180
+ code=(ACTIONS/"_temporary_submit.py").read_text()
181
+ assert 'goto_url(temporary_chat_entry_url())' in code
182
+ assert '"composer readiness was not observed ready" not in str(exc)' in code
183
+ assert 'if not _temporary_chat_enabled():' in code
@@ -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.