browser-workspace 0.4.2 → 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 (107) hide show
  1. package/README.md +15 -1
  2. package/SKILL.md +22 -21
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/package.json +1 -1
  5. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  6. package/platforms/chatgpt/tests/test_contract.py +7 -0
  7. package/pyproject.toml +6 -2
  8. package/session_daemon.py +91 -3
  9. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  10. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  11. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  12. package/src/browser_workspace.egg-info/requires.txt +1 -0
  13. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  14. package/src/browser_workspace_app/__init__.py +1 -0
  15. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  16. package/src/browser_workspace_app/cli.py +236 -0
  17. package/src/browser_workspace_app/extension/manifest.json +26 -0
  18. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  19. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  20. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  21. package/src/browser_workspace_app/extension/workspace.html +8 -0
  22. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  23. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  24. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  25. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  26. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  27. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  28. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  29. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  30. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  31. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  32. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  33. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  34. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  35. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  36. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  37. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  38. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  39. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  40. package/src/browser_workspace_app/managed_chrome.py +244 -0
  41. package/src/browser_workspace_app/node/admin.mjs +44 -0
  42. package/src/browser_workspace_app/package.json +36 -0
  43. package/src/browser_workspace_app/platform_runner.py +204 -0
  44. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  45. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  46. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  47. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  48. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  49. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  50. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  51. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  52. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  53. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  57. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  58. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  59. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  60. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  61. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  62. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  63. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  64. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  65. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  66. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  67. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  68. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  69. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  70. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  71. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  72. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  73. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  74. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  75. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  76. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  77. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  78. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  79. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  80. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  81. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  82. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  83. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  84. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  85. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  86. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  87. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  88. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  89. package/src/browser_workspace_app/pyproject.toml +33 -0
  90. package/src/browser_workspace_app/session_client.py +107 -0
  91. package/src/browser_workspace_app/session_daemon.py +431 -0
  92. package/src/browser_harness/__init__.py +0 -2
  93. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  94. package/src/browser_harness/_ipc.py +0 -201
  95. package/src/browser_harness/admin.py +0 -1588
  96. package/src/browser_harness/auth.py +0 -546
  97. package/src/browser_harness/daemon.py +0 -894
  98. package/src/browser_harness/helpers.py +0 -668
  99. package/src/browser_harness/macos.py +0 -131
  100. package/src/browser_harness/mcp_cli.py +0 -16
  101. package/src/browser_harness/paths.py +0 -49
  102. package/src/browser_harness/recorder.py +0 -343
  103. package/src/browser_harness/run.py +0 -413
  104. package/src/browser_harness/telemetry.py +0 -308
  105. package/src/browser_harness/video-template.html +0 -970
  106. package/src/browser_harness/video.py +0 -749
  107. package/src/browser_harness/video_render.py +0 -524
@@ -0,0 +1,44 @@
1
+ import net from "node:net";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ function socketPath() {
6
+ if (process.env.BROWSER_WORKSPACE_TEST_MODE === "1" && process.env.BROWSER_WORKSPACE_SESSION_SOCKET) return process.env.BROWSER_WORKSPACE_SESSION_SOCKET;
7
+ return path.join(os.homedir(), ".config", "browser-workspace", "runtime", "session.sock");
8
+ }
9
+
10
+ function request(payload) {
11
+ return new Promise((resolve, reject) => {
12
+ const socket = net.createConnection({ path: socketPath() });
13
+ let buffer = "";
14
+ let settled = false;
15
+ const fail = (error) => { if (!settled) { settled = true; reject(error); } };
16
+ socket.setEncoding("utf8");
17
+ socket.on("connect", () => socket.write(`${JSON.stringify(payload)}\n`));
18
+ socket.on("data", chunk => {
19
+ buffer += chunk;
20
+ const newline = buffer.indexOf("\n");
21
+ if (newline < 0 || settled) return;
22
+ settled = true;
23
+ socket.end();
24
+ try {
25
+ const result = JSON.parse(buffer.slice(0, newline));
26
+ if (result && result.error) reject(new Error(result.error));
27
+ else resolve(result);
28
+ } catch (error) { reject(error); }
29
+ });
30
+ socket.on("error", fail);
31
+ socket.on("end", () => { if (!settled) fail(new Error("browser-workspace daemon closed without a response")); });
32
+ });
33
+ }
34
+
35
+ export async function ensureWorkspace(name, size = 5) {
36
+ if (typeof name !== "string" || !name.trim()) throw new TypeError("workspace name must be a non-empty string");
37
+ if (!Number.isInteger(size) || size < 1) throw new TypeError("workspace size must be a positive integer");
38
+ return request({ op: "workspace-create", name: name.trim(), pool_size: size });
39
+ }
40
+
41
+ export async function deleteWorkspace(name, { force = false } = {}) {
42
+ if (typeof name !== "string" || !name.trim()) throw new TypeError("workspace name must be a non-empty string");
43
+ return request({ op: "workspace-delete", name: name.trim(), force: Boolean(force) });
44
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "browser-workspace",
3
+ "version": "0.4.5",
4
+ "description": "Agent browser workspace with persistent sessions, tab leasing, snapshots, and platform workflows.",
5
+ "bin": {
6
+ "browser-workspace": "bin/browser-workspace"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "node/",
11
+ "src/",
12
+ "agent-workspace/agent_helpers.py",
13
+ "interaction-skills/",
14
+ "platforms/",
15
+ "licenses/",
16
+ "platform_runner.py",
17
+ "session_client.py",
18
+ "session_daemon.py",
19
+ "pyproject.toml",
20
+ "README.md",
21
+ "SKILL.md",
22
+ "PRIVACY.md",
23
+ "managed_chrome.py",
24
+ "extension/"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "scripts": {
30
+ "test": "python3 -m pytest -q test && node --test test/workspace-manager.test.mjs"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/lalalic/browser-workspace.git"
35
+ }
36
+ }
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from session_client import request
11
+
12
+ ROOT = Path(__file__).resolve().parent
13
+ PLATFORMS = ROOT / "platforms"
14
+
15
+
16
+ def platform_manifest(platform: str) -> dict:
17
+ platform = platform.strip()
18
+ manifest = PLATFORMS / platform / "manifest.yaml"
19
+ if not manifest.is_file():
20
+ raise FileNotFoundError(f"unknown platform: {platform}")
21
+ data = yaml.safe_load(manifest.read_text())
22
+ if not isinstance(data, dict):
23
+ raise ValueError(f"invalid platform manifest: {platform}")
24
+ return data
25
+
26
+
27
+ def platform_profile(platform: str) -> dict:
28
+ data = platform_manifest(platform)
29
+ keys = (
30
+ "platform", "display_name", "aliases", "implementation_status",
31
+ "media", "fields", "capabilities", "content_mapping", "notes",
32
+ )
33
+ return {key: data[key] for key in keys if key in data}
34
+
35
+
36
+ def _validate_string(name: str, value, spec: dict) -> None:
37
+ if value is None:
38
+ return
39
+ text = str(value)
40
+ minimum = spec.get("min_chars")
41
+ maximum = spec.get("max_chars") or spec.get("max_chars_including_tags")
42
+ if minimum is not None and text and len(text) < int(minimum):
43
+ raise ValueError(f"{name} too short: {len(text)} chars (min {minimum})")
44
+ if maximum is not None and len(text) > int(maximum):
45
+ raise ValueError(f"{name} too long: {len(text)} chars (max {maximum})")
46
+
47
+
48
+ def validate_platform_config(platform: str, action: str, config: dict) -> None:
49
+ if action != "post":
50
+ return
51
+ profile = platform_profile(platform)
52
+ fields = profile.get("fields") or {}
53
+ for name, spec in fields.items():
54
+ if not isinstance(spec, dict) or spec.get("type") == "operation":
55
+ continue
56
+ value = config.get(name)
57
+ if spec.get("required") and (value is None or value == "" or value == []):
58
+ raise ValueError(f"{platform}: missing required field {name}")
59
+ if value is None:
60
+ continue
61
+ kind = spec.get("type")
62
+ if kind == "string":
63
+ _validate_string(name, value, spec)
64
+ elif kind == "string_list":
65
+ values = value if isinstance(value, list) else [x.strip() for x in str(value).split(",") if x.strip()]
66
+ maximum = spec.get("max_count")
67
+ if maximum is not None and len(values) > int(maximum):
68
+ raise ValueError(f"{name} has {len(values)} items (max {maximum})")
69
+ each = spec.get("max_chars_each")
70
+ if each is not None:
71
+ for item in values:
72
+ if len(str(item)) > int(each):
73
+ raise ValueError(f"{name} item too long: {item!r} (max {each})")
74
+ total = spec.get("max_total_chars")
75
+ if total is not None and sum(len(str(x)) for x in values) > int(total):
76
+ raise ValueError(f"{name} total length exceeds {total}")
77
+ elif kind == "enum":
78
+ values = spec.get("values") or []
79
+ if value not in values:
80
+ raise ValueError(f"{name} must be one of: {', '.join(map(str, values))}")
81
+
82
+ images_spec = (profile.get("media") or {}).get("images") or {}
83
+ images = config.get("images") or []
84
+ maximum = images_spec.get("max_count")
85
+ if maximum is not None and len(images) > int(maximum):
86
+ raise ValueError(f"images has {len(images)} items (max {maximum})")
87
+
88
+
89
+ def declared_actions(platform: str) -> dict[str, Path]:
90
+ platform = platform.strip()
91
+ manifest = PLATFORMS / platform / "manifest.yaml"
92
+ if not manifest.is_file():
93
+ raise FileNotFoundError(f"unknown platform: {platform}")
94
+
95
+ text = manifest.read_text()
96
+ match = re.search(
97
+ r"^actions:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)",
98
+ text,
99
+ flags=re.MULTILINE,
100
+ )
101
+ if not match:
102
+ raise ValueError(f"{platform}/manifest.yaml has no actions block")
103
+
104
+ actions: dict[str, Path] = {}
105
+ for line in match.group("body").splitlines():
106
+ entry = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
107
+ if not entry:
108
+ continue
109
+ name, relative = entry.groups()
110
+ if len(relative) >= 2 and relative[0] == relative[-1] and relative[0] in {"\"", "'"}:
111
+ relative = relative[1:-1]
112
+ # Actions are simple relative file paths; comments and nested mappings are not runnable actions.
113
+ if not relative or relative.startswith(("#", "{", "[")) or " #" in relative:
114
+ continue
115
+ path = PLATFORMS / platform / relative
116
+ if not path.is_file():
117
+ raise FileNotFoundError(f"declared platform action missing: {platform}/{name} -> {relative}")
118
+ actions[name] = path
119
+ if not actions:
120
+ raise ValueError(f"{platform}/manifest.yaml declares no runnable actions")
121
+ return actions
122
+
123
+
124
+ def action_path(platform: str, action: str) -> Path:
125
+ platform = platform.strip()
126
+ action = action.strip().replace("_", "-")
127
+ actions = declared_actions(platform)
128
+ if action not in actions:
129
+ raise FileNotFoundError(
130
+ f"unknown platform action {platform}/{action}; "
131
+ f"available: {', '.join(sorted(actions)) or 'none'}"
132
+ )
133
+ return actions[action]
134
+
135
+
136
+ def prepare_action(path: Path, config_path: Path | None = None) -> str:
137
+ source = path.read_text()
138
+ source = "import sys\nsys.path.insert(0, " + repr(str(path.parent.resolve())) + ")\n" + source
139
+ if "__CFG_PATH__" in source:
140
+ if config_path is None:
141
+ raise ValueError(f"{path.parent.parent.name}/{path.stem}: --config is required")
142
+ escaped = str(config_path.resolve()).replace("\\", "\\\\").replace('"', '\\"')
143
+ source = source.replace("__CFG_PATH__", escaped)
144
+ return source
145
+
146
+
147
+ def run_platform_action(
148
+ platform: str,
149
+ action: str,
150
+ *,
151
+ config_path: str | None = None,
152
+ url: str | None = None,
153
+ session_id: str | None = None,
154
+ auto_session: bool = False,
155
+ ) -> dict:
156
+ path = action_path(platform, action)
157
+ config_file = Path(config_path) if config_path else None
158
+ if config_file is not None:
159
+ config = json.loads(config_file.read_text())
160
+ if not isinstance(config, dict):
161
+ raise ValueError("platform config must be a JSON object")
162
+ validate_platform_config(platform, action, config)
163
+ code = prepare_action(path, config_file)
164
+
165
+ if not session_id and not auto_session:
166
+ raise ValueError(
167
+ "platform run requires session_id; orchestrators should use: "
168
+ "session start -> platform run --session-id SESSION_ID -> self-heal in the same session -> session stop. "
169
+ "Use auto_session=True only for a one-shot run without orchestrator self-heal."
170
+ )
171
+ if session_id and auto_session:
172
+ raise ValueError("session_id and auto_session are mutually exclusive")
173
+
174
+ owned_session = bool(auto_session)
175
+ started = request({"op": "start", "workspace": None, "url": url}) if owned_session else {"session_id": session_id, "workspace_supported": None}
176
+ sid = started["session_id"]
177
+ result = None
178
+ cleanup = None
179
+ try:
180
+ raw_result = request({"op": "exec", "session_id": sid, "code": code})
181
+ result = {k: v for k, v in raw_result.items() if k not in {"session_id", "workspace", "target_id"}}
182
+ output = {
183
+ "platform": platform,
184
+ "action": action,
185
+ "workspace_supported": started.get("workspace_supported"),
186
+ "session_mode": "auto_session_no_self_heal" if owned_session else "caller_owned_self_heal_capable",
187
+ "self_heal_capable": not owned_session,
188
+ "result": result,
189
+ }
190
+ if started.get("warning"):
191
+ output["warning"] = started["warning"]
192
+ output["extension_url"] = started.get("extension_url")
193
+ return output
194
+ finally:
195
+ if owned_session:
196
+ try:
197
+ raw_cleanup = request({"op": "stop", "session_id": sid})
198
+ cleanup = {k: v for k, v in raw_cleanup.items() if k not in {"session_id", "workspace", "target_id"}}
199
+ except Exception as exc:
200
+ cleanup = {"release_error": f"{type(exc).__name__}: {exc}"}
201
+ else:
202
+ cleanup = {"caller_owned_session": True}
203
+ if result is not None:
204
+ result["cleanup"] = cleanup
@@ -0,0 +1,60 @@
1
+ # ChatGPT browser platform
2
+
3
+ Canonical reusable browser mechanics for ChatGPT.
4
+
5
+ This platform owns ChatGPT-specific page behavior. Callers own lifecycle:
6
+
7
+ - Agents Relay: disposable worker lifecycle and event/barrier ownership.
8
+ - NeoY Tutor Workspace: persistent learner/thread lifecycle.
9
+
10
+ Use Browser Workspace as the only browser-control engine. Do not duplicate ChatGPT
11
+ selectors or submit/result-completion logic in callers.
12
+
13
+ `temporary-submit` owns the reusable Temporary Chat page mechanics and verified submit receipt. Callers that need delayed cleanup should render the action source into a caller-owned Browser Workspace session and stop that session at their lifecycle barrier.
14
+
15
+ ## Web ChatGPT MCP apps
16
+
17
+ Use the `mcp-app` action for repeatable inspection of an installed custom MCP app and for a safe tool refresh. It deliberately uses semantic text/role discovery rather than transient snapshot refs such as `e31`.
18
+
19
+ ```bash
20
+ cat > /tmp/chatgpt-mcp-app.json <<'JSON'
21
+ {"app_name":"neo","operation":"status"}
22
+ JSON
23
+ browser-workspace platform run chatgpt mcp-app --config /tmp/chatgpt-mcp-app.json
24
+ ```
25
+
26
+ A connected app is verified by its detail page exposing `Permissions`, `Disconnect`, and `Refresh tools`. The action returns these as structured booleans.
27
+
28
+ ### Configure a custom MCP app
29
+
30
+ The proven Web ChatGPT flow is:
31
+
32
+ 1. Start/reuse a Browser Workspace session on `https://chatgpt.com` and take a fresh `snapshot()` before interacting. Never reuse `eNN` refs from an earlier snapshot.
33
+ 2. Open the visible profile menu by semantic label `Open profile menu`, choose `Settings`, then choose `Plugins`. The durable settings route observed in October 2026 is `/settings/plugins-settings`, but prefer semantic navigation because routes can drift.
34
+ 3. From Plugins, use the create/add-custom-app control. Enter the app display name and its public HTTPS MCP endpoint. Keep endpoint/client/OAuth values in caller configuration; never commit credentials to this platform.
35
+ 4. Ask ChatGPT to scan/discover the MCP tools. If OAuth is advertised by the MCP server, continue the OAuth authorization flow and return to ChatGPT.
36
+ 5. Finish creating/installing the app, then reopen Settings -> Plugins and select the app by exact display name.
37
+ 6. Verify the detail page exposes `Permissions`, `Disconnect`, and `Refresh tools`. `Disconnect` is the positive signal that the account/OAuth connection is established. Run `mcp-app` with `operation=status` to capture this verification programmatically.
38
+ 7. After an MCP server descriptor/tool change, use `operation=refresh-tools`, then inspect status again before testing tool calls.
39
+
40
+ ### Snapshot-first fallback for UI drift
41
+
42
+ If a semantic selector no longer resolves, do not guess coordinates or hard-code old refs. In the same Browser Workspace session:
43
+
44
+ ```python
45
+ print(snapshot(interactive_only=False))
46
+ ```
47
+
48
+ Use the fresh accessibility/DOM output to rediscover the visible control by role, accessible label, or stable text. When a menu is lazy-loaded, trigger the visible semantic control, wait for hydration, then snapshot again. This is how the profile menu -> Settings -> Plugins path was recovered during the live MCP setup.
49
+
50
+ Destructive controls (`Disconnect`, `Uninstall`, `Delete app`) are intentionally not exposed as `mcp-app` operations.
51
+
52
+
53
+ ## XChat lifecycle control transfer
54
+
55
+ Browser Workspace owns the ChatGPT UI mechanics for orchestrator lifecycle handoff.
56
+
57
+ - `new-turn`: given stable `project_id`, `thread_id`, and `message`, wait until the current assistant turn is no longer generating, submit the continuation as the next user turn in the same thread, verify acceptance, and return without waiting for the new assistant answer.
58
+ - `new-thread`: given stable `project_id` and `message` (plus optional `source_thread_id` provenance), open the same Project, submit the continuation to create a fresh thread, and return the durable new `thread_id` once ChatGPT replaces any temporary local id.
59
+
60
+ These actions are control-transfer primitives. The generic Browser Workspace runner owns session start/stop and cleanup. Product code should pass stable Project/Thread IDs rather than persist ChatGPT URLs or DOM identities.
@@ -0,0 +1,58 @@
1
+ import json
2
+ import time
3
+ from urllib.parse import urlsplit
4
+
5
+ CFG=json.load(open("__CFG_PATH__",encoding="utf-8"))
6
+
7
+ def wait(pred,label,timeout=30):
8
+ deadline=time.time()+timeout
9
+ while time.time()<deadline:
10
+ value=pred()
11
+ if value:return value
12
+ time.sleep(.2)
13
+ raise RuntimeError(f"{label} not observed")
14
+
15
+ def composer():
16
+ return js("""(() => {
17
+ const sels=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]','textarea'];
18
+ for(const s of sels){const e=document.querySelector(s);if(!e)continue;const r=e.getBoundingClientRect();if(r.width&&r.height)return s;}
19
+ return null;
20
+ })()""")
21
+
22
+ def set_value(selector,text):
23
+ ok=js(f"""(() => {{
24
+ const e=document.querySelector({json.dumps(selector)});
25
+ if(!e)return false;e.focus();
26
+ if(e instanceof HTMLTextAreaElement || e instanceof HTMLInputElement){{
27
+ const p=e instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
28
+ Object.getOwnPropertyDescriptor(p,'value')?.set?.call(e,{json.dumps(text)});
29
+ }}else{{
30
+ const sel=window.getSelection(),r=document.createRange();r.selectNodeContents(e);sel.removeAllRanges();sel.addRange(r);
31
+ document.execCommand('delete',false,null);document.execCommand('insertText',false,{json.dumps(text)});sel.removeAllRanges();
32
+ }}
33
+ e.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:{json.dumps(text)}}}));
34
+ return true;
35
+ }})()""")
36
+ if not ok: raise RuntimeError("bootstrap composer could not be filled")
37
+
38
+ target_id=new_tab("https://chatgpt.com/")
39
+ wait_for_load()
40
+ selector=wait(composer,"ChatGPT bootstrap composer",45)
41
+ set_value(selector,CFG["instructions"])
42
+ button=wait(lambda: js("""(() => {
43
+ const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]');
44
+ if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return null;
45
+ b.setAttribute('data-neoy-bootstrap-send','1');return '[data-neoy-bootstrap-send="1"]';
46
+ })()"""),"ChatGPT bootstrap send button",20)
47
+ if not js("""(() => {const b=document.querySelector('[data-neoy-bootstrap-send="1"]');if(!b)return false;b.click();return true;})()"""):
48
+ raise RuntimeError("bootstrap send failed")
49
+ deadline=time.time()+90
50
+ thread_url=None
51
+ while time.time()<deadline:
52
+ current=page_info().get("url","")
53
+ if "/c/" in urlsplit(current).path and "local-chatgpt" not in current:
54
+ thread_url=current.split("?",1)[0]
55
+ break
56
+ time.sleep(.25)
57
+ if not thread_url: raise RuntimeError("bootstrap thread durable URL not observed")
58
+ print(json.dumps({"status":"started","thread_url":thread_url,"target_id":target_id},ensure_ascii=False),flush=True)
@@ -0,0 +1,119 @@
1
+ import json
2
+ import time
3
+ from _readiness import wait_until_stable, submission_receipt, prompt_text_matches
4
+
5
+
6
+ def composer_selector():
7
+ return js("""(() => {
8
+ const selectors=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]','textarea'];
9
+ for (const selector of selectors) {
10
+ const e=document.querySelector(selector);
11
+ if (!e || e.disabled) continue;
12
+ const r=e.getBoundingClientRect();
13
+ if (r.width>0 && r.height>0) return selector;
14
+ }
15
+ return null;
16
+ })()""")
17
+
18
+
19
+ def composer_text(selector=None):
20
+ selector=selector or composer_selector()
21
+ if not selector: return ""
22
+ return js(f"""(() => {{
23
+ const e=document.querySelector({json.dumps(selector)});
24
+ if (!e) return '';
25
+ return (e.innerText || e.value || e.textContent || '').trim();
26
+ }})()""") or ""
27
+
28
+
29
+ def send_enabled():
30
+ return bool(js("""(() => {
31
+ const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]');
32
+ return !!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true';
33
+ })()"""))
34
+
35
+
36
+ def is_generating():
37
+ return bool(js("""(() => !![...document.querySelectorAll('button')].find(b => {
38
+ const a=(b.getAttribute('aria-label')||'').toLowerCase();
39
+ const t=(b.textContent||'').trim().toLowerCase();
40
+ const id=(b.getAttribute('data-testid')||'').toLowerCase();
41
+ return a.includes('stop answering') || a.includes('stop generating') || t === 'stop' || id.includes('stop');
42
+ }))()"""))
43
+
44
+
45
+ def user_turns():
46
+ return js("""(() => {
47
+ const legacy=[...document.querySelectorAll('[data-message-author-role="user"]')]
48
+ .map(e=>({text:(e.innerText||'').trim(),id:e.getAttribute('data-message-id')||''})).filter(x=>x.text);
49
+ if (legacy.length) return legacy;
50
+ return [...document.querySelectorAll('h4')]
51
+ .filter(e=>(e.innerText||'').trim()==='You said:')
52
+ .map((e,index)=>({text:(e.parentElement?.innerText||'').replace(/^You said:\\s*/,'').trim(),id:`dom-user-${index}`}))
53
+ .filter(x=>x.text);
54
+ })()""") or []
55
+
56
+
57
+ def wait_for_idle(timeout=300):
58
+ return wait_until_stable(
59
+ lambda: {"composer": composer_selector(), "generating": is_generating()},
60
+ lambda state: bool(state["composer"]) and not state["generating"],
61
+ timeout=timeout,
62
+ interval=.25,
63
+ stable_samples=3,
64
+ phase="ChatGPT turn completion",
65
+ )
66
+
67
+
68
+ def fill_prompt(prompt):
69
+ selector=wait_until_stable(
70
+ lambda: composer_selector(),
71
+ lambda value: bool(value),
72
+ timeout=30,
73
+ phase="ChatGPT composer readiness",
74
+ )
75
+ editable=bool(js(f"""(() => document.querySelector({json.dumps(selector)})?.getAttribute('contenteditable')==='true')()"""))
76
+ if editable:
77
+ chunks=[prompt[i:i+256] for i in range(0, len(prompt), 256)]
78
+ ok=js(f"""(() => {{
79
+ const e=document.querySelector({json.dumps(selector)});
80
+ if(!e)return false;
81
+ e.focus();
82
+ const sel=window.getSelection(), range=document.createRange();
83
+ range.selectNodeContents(e); sel.removeAllRanges(); sel.addRange(range);
84
+ document.execCommand('delete',false,null); sel.removeAllRanges();
85
+ for (const chunk of {json.dumps(chunks)}) {{
86
+ const current=window.getSelection(), r=document.createRange();
87
+ r.selectNodeContents(e); r.collapse(false); current.removeAllRanges(); current.addRange(r);
88
+ if(!document.execCommand('insertText',false,chunk)) return false;
89
+ }}
90
+ e.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:{json.dumps(prompt)}}}));
91
+ return true;
92
+ }})()""")
93
+ if not ok: raise RuntimeError("ChatGPT composer rejected lifecycle continuation")
94
+ else:
95
+ fill_input(selector,prompt,clear_first=True)
96
+ wait_until_stable(
97
+ lambda: {"text":composer_text(selector),"send":send_enabled()},
98
+ lambda state: prompt_text_matches(state["text"],prompt) and state["send"],
99
+ timeout=20,
100
+ phase="ChatGPT lifecycle continuation readiness",
101
+ )
102
+ return selector
103
+
104
+
105
+ def submit(prompt):
106
+ before=len(user_turns())
107
+ selector=fill_prompt(prompt)
108
+ clicked=js("""(() => {
109
+ const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]');
110
+ if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return false;
111
+ b.click(); return true;
112
+ })()""")
113
+ if not clicked: raise RuntimeError("ChatGPT send button changed before lifecycle submit")
114
+ deadline=time.time()+30
115
+ while time.time()<deadline:
116
+ receipt=submission_receipt(user_turns(),before,prompt,composer_text(selector))
117
+ if receipt: return receipt
118
+ time.sleep(.2)
119
+ raise RuntimeError("ChatGPT lifecycle submission was not observed")
@@ -0,0 +1,80 @@
1
+ import json
2
+ import time
3
+
4
+ CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
5
+ APP_NAME = str(CFG.get("app_name") or "").strip()
6
+ OP = str(CFG.get("operation") or "status").strip()
7
+
8
+ if not APP_NAME:
9
+ raise ValueError("app_name is required")
10
+ if OP not in {"status", "open", "refresh-tools"}:
11
+ raise ValueError("operation must be status, open, or refresh-tools")
12
+
13
+
14
+ def wait_until(pred, label, timeout=30):
15
+ deadline = time.time() + timeout
16
+ while time.time() < deadline:
17
+ value = pred()
18
+ if value:
19
+ return value
20
+ time.sleep(0.2)
21
+ raise RuntimeError(f"{label} not observed")
22
+
23
+
24
+ def app_button_exists():
25
+ return js(f"""(() => {{
26
+ const name={json.dumps(APP_NAME)};
27
+ return Array.from(document.querySelectorAll('button,a')).some(el => {{
28
+ const text=(el.innerText||'').trim();
29
+ return text===name || text.startsWith(name+'\\n');
30
+ }});
31
+ }})()""")
32
+
33
+
34
+ def open_app():
35
+ ok = js(f"""(() => {{
36
+ const name={json.dumps(APP_NAME)};
37
+ const el=Array.from(document.querySelectorAll('button,a')).find(el => {{
38
+ const text=(el.innerText||'').trim();
39
+ return text===name || text.startsWith(name+'\\n');
40
+ }});
41
+ if(!el)return false; el.click(); return true;
42
+ }})()""")
43
+ if not ok:
44
+ raise RuntimeError(f"installed ChatGPT plugin not found: {APP_NAME}")
45
+ wait_until(lambda: js("document.body?.innerText?.includes('Refresh tools') || document.body?.innerText?.includes('Disconnect')"), "plugin detail controls")
46
+
47
+
48
+ def detail_state():
49
+ return js("""(() => {
50
+ const body=(document.body?.innerText||'');
51
+ const texts=Array.from(document.querySelectorAll('button,a')).map(el=>(el.innerText||el.getAttribute('aria-label')||'').trim()).filter(Boolean);
52
+ return {
53
+ connected:body.includes('Disconnect'),
54
+ permissions:body.includes('Permissions'),
55
+ refresh_tools:texts.includes('Refresh tools'),
56
+ editable:texts.includes('Edit'),
57
+ uninstallable:texts.includes('Uninstall'),
58
+ url:location.href
59
+ };
60
+ })()""")
61
+
62
+
63
+ goto_url("https://chatgpt.com/settings/plugins-settings")
64
+ wait_for_load()
65
+ wait_until(lambda: js("document.body?.innerText?.includes('Plugins')"), "ChatGPT Plugins settings")
66
+ wait_until(app_button_exists, f"installed plugin {APP_NAME}")
67
+ open_app()
68
+
69
+ if OP == "refresh-tools":
70
+ ok = js("""(() => {
71
+ const b=Array.from(document.querySelectorAll('button')).find(x=>(x.innerText||'').trim()==='Refresh tools');
72
+ if(!b||b.disabled)return false; b.click(); return true;
73
+ })()""")
74
+ if not ok:
75
+ raise RuntimeError("Refresh tools control is unavailable")
76
+ time.sleep(0.5)
77
+
78
+ wait_until(lambda: js("document.body?.innerText?.includes('Refresh tools') || document.body?.innerText?.includes('Disconnect')"), "plugin detail hydration")
79
+ state = detail_state()
80
+ print(json.dumps({"app_name": APP_NAME, "operation": OP, **state}, ensure_ascii=False), flush=True)