@widgex/wgx 0.0.0-stage → 0.4.2
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/LICENSE.txt +49 -0
- package/README.md +33 -2
- package/ThirdPartyNotices.txt +4685 -0
- package/package.json +30 -4
- package/provider-clis.json +1211 -0
- package/python/XACTOR.md +146 -0
- package/python/browser_actor_host.py +456 -0
- package/python/cdp_page.py +363 -0
- package/python/harness_check.py +77 -0
- package/python/requirements.txt +1 -0
- package/python/test_line_endings.py +185 -0
- package/python/xactor/__init__.py +18 -0
- package/python/xactor/__main__.py +226 -0
- package/python/xactor/api.py +882 -0
- package/python/xactor/bridge.py +784 -0
- package/python/xactor/dom.py +117 -0
- package/python/xactor/man/add_card.md +89 -0
- package/python/xactor/man/agent-vs-fork.md +108 -0
- package/python/xactor/man/agent.md +333 -0
- package/python/xactor/man/ask_model.md +110 -0
- package/python/xactor/man/ask_reference.md +56 -0
- package/python/xactor/man/ask_user.md +103 -0
- package/python/xactor/man/attach_app.md +595 -0
- package/python/xactor/man/attach_ide.md +65 -0
- package/python/xactor/man/attach_ide_surface.md +31 -0
- package/python/xactor/man/await_user.md +214 -0
- package/python/xactor/man/bash.md +142 -0
- package/python/xactor/man/browser-driver.md +137 -0
- package/python/xactor/man/browser_activity.md +53 -0
- package/python/xactor/man/browser_attach.md +84 -0
- package/python/xactor/man/browser_close_tab.md +50 -0
- package/python/xactor/man/browser_detach.md +50 -0
- package/python/xactor/man/browser_exec.md +66 -0
- package/python/xactor/man/browser_focus_tab.md +47 -0
- package/python/xactor/man/browser_hybrid.md +47 -0
- package/python/xactor/man/browser_open.md +50 -0
- package/python/xactor/man/browser_open_tab.md +58 -0
- package/python/xactor/man/browser_plumbing.md +107 -0
- package/python/xactor/man/browser_screenshot.md +56 -0
- package/python/xactor/man/browser_snapshot.md +52 -0
- package/python/xactor/man/browser_tabs.md +67 -0
- package/python/xactor/man/bulletin.md +88 -0
- package/python/xactor/man/capture_page.md +54 -0
- package/python/xactor/man/cd.md +64 -0
- package/python/xactor/man/chess_move.md +53 -0
- package/python/xactor/man/children.md +89 -0
- package/python/xactor/man/close_task.md +14 -0
- package/python/xactor/man/compact.md +75 -0
- package/python/xactor/man/drive.md +140 -0
- package/python/xactor/man/drive_space.md +72 -0
- package/python/xactor/man/driving.md +168 -0
- package/python/xactor/man/edit_file.md +112 -0
- package/python/xactor/man/finding.md +194 -0
- package/python/xactor/man/fork_agent.md +45 -0
- package/python/xactor/man/glob.md +83 -0
- package/python/xactor/man/grep.md +115 -0
- package/python/xactor/man/harness.md +165 -0
- package/python/xactor/man/harness_check.md +70 -0
- package/python/xactor/man/harness_format.md +257 -0
- package/python/xactor/man/highlight.md +66 -0
- package/python/xactor/man/instruct.md +111 -0
- package/python/xactor/man/ls.md +63 -0
- package/python/xactor/man/man.md +86 -0
- package/python/xactor/man/merge_wiz.md +40 -0
- package/python/xactor/man/notebook.md +98 -0
- package/python/xactor/man/overview.md +89 -0
- package/python/xactor/man/permissions.md +137 -0
- package/python/xactor/man/phase.md +150 -0
- package/python/xactor/man/powershell.md +74 -0
- package/python/xactor/man/profiles.md +60 -0
- package/python/xactor/man/project_drive.md +49 -0
- package/python/xactor/man/read_bulletin.md +89 -0
- package/python/xactor/man/read_file.md +90 -0
- package/python/xactor/man/relay.md +216 -0
- package/python/xactor/man/reply.md +72 -0
- package/python/xactor/man/reply_message.md +78 -0
- package/python/xactor/man/report_bug.md +118 -0
- package/python/xactor/man/request_answer.md +142 -0
- package/python/xactor/man/request_merge_decisions.md +121 -0
- package/python/xactor/man/request_operator_action.md +152 -0
- package/python/xactor/man/request_review.md +122 -0
- package/python/xactor/man/request_user_action.md +126 -0
- package/python/xactor/man/return_card.md +71 -0
- package/python/xactor/man/run.md +99 -0
- package/python/xactor/man/sandbox.md +144 -0
- package/python/xactor/man/session_fork.md +74 -0
- package/python/xactor/man/session_harness.md +109 -0
- package/python/xactor/man/session_join.md +102 -0
- package/python/xactor/man/session_land.md +73 -0
- package/python/xactor/man/session_nudge.md +73 -0
- package/python/xactor/man/session_rest.md +73 -0
- package/python/xactor/man/session_scope.md +84 -0
- package/python/xactor/man/session_terminate.md +58 -0
- package/python/xactor/man/session_token.md +78 -0
- package/python/xactor/man/sessions.md +93 -0
- package/python/xactor/man/set_harness.md +15 -0
- package/python/xactor/man/spawn_swarm.md +80 -0
- package/python/xactor/man/submit_evidence.md +54 -0
- package/python/xactor/man/swarm_brief.md +30 -0
- package/python/xactor/man/task_status.md +82 -0
- package/python/xactor/man/tasks.md +47 -0
- package/python/xactor/man/todo_write.md +104 -0
- package/python/xactor/man/update_card.md +70 -0
- package/python/xactor/man/use_profile.md +90 -0
- package/python/xactor/man/use_widget.md +82 -0
- package/python/xactor/man/wait_for_task.md +105 -0
- package/python/xactor/man/widget-model.md +284 -0
- package/python/xactor/man/widgets.md +59 -0
- package/python/xactor/man/write_file.md +76 -0
- package/python/xactor/man/writing-a-widget.md +142 -0
- package/python/xactor/man/xylon.md +120 -0
- package/python/xactor/man/xylon_blame.md +80 -0
- package/python/xactor/man/xylon_read.md +131 -0
- package/python/xactor/man/xylon_search.md +125 -0
- package/python/xactor/manual.py +195 -0
- package/python/xactor/parent_watch.py +115 -0
- package/python/xactor/plan_mode.py +350 -0
- package/python/xactor/sandbox.py +856 -0
- package/python/xactor/seals.py +438 -0
- package/python/xactor/shell_hints.py +210 -0
- package/python/xactor/states/__init__.py +13 -0
- package/python/xactor/states/base.py +523 -0
- package/python/xactor/states/registry.py +274 -0
- package/python/xactor/stdlib_api.py +6345 -0
- package/python/xactor/widgets/__init__.py +1 -0
- package/python/xactor/widgets/github_tokens.py +314 -0
- package/python/xactor/widgets/google_aistudio.py +365 -0
- package/settings-schema.json +403 -0
- package/viewer/index.css +160 -0
- package/viewer/index.js +15 -0
- package/wgx.mjs +290659 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cdp_page.py — Minimal CDP page wrapper over a raw WebSocket connection.
|
|
3
|
+
|
|
4
|
+
Connects directly to a Chrome DevTools Protocol target via its
|
|
5
|
+
webSocketDebuggerUrl. No Playwright dependency needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Callable, Awaitable
|
|
13
|
+
|
|
14
|
+
import websockets
|
|
15
|
+
|
|
16
|
+
# Product tokens a stock desktop Chrome sends. Anything else shaped like
|
|
17
|
+
# `Name/Version` is an embedder stamp — `Electron/39.3.0`, `WidgeX/1.109.0-oss`.
|
|
18
|
+
# Kept as an allow-list so the next fork that brands the UA is caught too.
|
|
19
|
+
_STOCK_TOKENS = re.compile(r"^(Mozilla|AppleWebKit|Chrome|Safari)/")
|
|
20
|
+
_PRODUCT_TOKEN = re.compile(r"^[A-Za-z][\w.+-]*/[\w.+-]+$")
|
|
21
|
+
_CHROME_VERSION = re.compile(r"\bChrome/(\d+(?:\.\d+)*)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sanitize_user_agent(ua: str) -> str:
|
|
25
|
+
"""Strip non-Chrome product tokens from a User-Agent string.
|
|
26
|
+
|
|
27
|
+
`Electron/` is a default-deny token at the CDN edge, matched on the first
|
|
28
|
+
request before any JavaScript runs. The TLS and HTTP/2 fingerprints beneath
|
|
29
|
+
are already stock Chrome, so the header is the whole problem.
|
|
30
|
+
|
|
31
|
+
Non-Chromium strings are returned unchanged — there is nothing to normalise
|
|
32
|
+
them towards.
|
|
33
|
+
"""
|
|
34
|
+
if not ua or not _CHROME_VERSION.search(ua):
|
|
35
|
+
return ua
|
|
36
|
+
kept = [
|
|
37
|
+
tok for tok in ua.split(" ")
|
|
38
|
+
if not _PRODUCT_TOKEN.match(tok) or _STOCK_TOKENS.match(tok)
|
|
39
|
+
]
|
|
40
|
+
return re.sub(r"\s{2,}", " ", " ".join(kept)).strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_grease(brand: str) -> bool:
|
|
44
|
+
return "not" in brand.lower() and "brand" in brand.lower()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_user_agent_metadata(ua: str, hints: dict | None) -> dict:
|
|
48
|
+
"""Client hints consistent with a sanitised UA string.
|
|
49
|
+
|
|
50
|
+
A UA claiming Chrome/142 beside a ``Sec-CH-UA`` list carrying no "Google
|
|
51
|
+
Chrome" entry — which is all Electron reports — is a louder signal than the
|
|
52
|
+
token just removed, so the two have to move together. Real high-entropy
|
|
53
|
+
values are passed through untouched; only the brands are rewritten.
|
|
54
|
+
"""
|
|
55
|
+
match = _CHROME_VERSION.search(ua)
|
|
56
|
+
full_version = match.group(1) if match else ""
|
|
57
|
+
major = full_version.split(".")[0] if full_version else ""
|
|
58
|
+
hints = hints or {}
|
|
59
|
+
|
|
60
|
+
# Preserve the browser's own GREASE brand: its purpose is to vary per build,
|
|
61
|
+
# and a value of ours would be a constant across every install.
|
|
62
|
+
pool = list(hints.get("brands") or []) + list(hints.get("fullVersionList") or [])
|
|
63
|
+
grease = next((b for b in pool if _is_grease(str(b.get("brand", "")))), None)
|
|
64
|
+
|
|
65
|
+
brands = []
|
|
66
|
+
full_list = []
|
|
67
|
+
if grease:
|
|
68
|
+
name = grease["brand"]
|
|
69
|
+
version = str(grease.get("version", ""))
|
|
70
|
+
brands.append({"brand": name, "version": version.split(".")[0] or version})
|
|
71
|
+
full = next(
|
|
72
|
+
(b for b in (hints.get("fullVersionList") or []) if b.get("brand") == name),
|
|
73
|
+
None,
|
|
74
|
+
)
|
|
75
|
+
full_list.append({"brand": name, "version": (full or grease).get("version", version)})
|
|
76
|
+
for name in ("Chromium", "Google Chrome"):
|
|
77
|
+
brands.append({"brand": name, "version": major})
|
|
78
|
+
full_list.append({"brand": name, "version": full_version})
|
|
79
|
+
|
|
80
|
+
if re.search(r"aarch64|arm64|ARM64", ua):
|
|
81
|
+
architecture, bitness = "arm", "64"
|
|
82
|
+
elif re.search(r"Win64|x86_64|x64|WOW64", ua):
|
|
83
|
+
architecture, bitness = "x86", "64"
|
|
84
|
+
else:
|
|
85
|
+
architecture, bitness = "x86", "32"
|
|
86
|
+
|
|
87
|
+
if "Windows" in ua:
|
|
88
|
+
platform = "Windows"
|
|
89
|
+
elif "Mac OS X" in ua or "Macintosh" in ua:
|
|
90
|
+
platform = "macOS"
|
|
91
|
+
elif "Android" in ua:
|
|
92
|
+
platform = "Android"
|
|
93
|
+
elif "CrOS" in ua:
|
|
94
|
+
platform = "Chrome OS"
|
|
95
|
+
else:
|
|
96
|
+
platform = "Linux"
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"brands": brands,
|
|
100
|
+
"fullVersionList": full_list,
|
|
101
|
+
"fullVersion": full_version,
|
|
102
|
+
"platform": hints.get("platform") or platform,
|
|
103
|
+
"platformVersion": hints.get("platformVersion") or "",
|
|
104
|
+
"architecture": hints.get("architecture") or architecture,
|
|
105
|
+
"model": hints.get("model") or "",
|
|
106
|
+
"mobile": bool(hints.get("mobile", False)),
|
|
107
|
+
"bitness": hints.get("bitness") or bitness,
|
|
108
|
+
"wow64": bool(hints.get("wow64", False)),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_HIGH_ENTROPY_EXPRESSION = """(navigator.userAgentData
|
|
113
|
+
? navigator.userAgentData.getHighEntropyValues(
|
|
114
|
+
['platform','platformVersion','architecture','bitness','model','fullVersionList'])
|
|
115
|
+
.then(v => ({ ...v, brands: navigator.userAgentData.brands }))
|
|
116
|
+
.catch(() => null)
|
|
117
|
+
: null)"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CdpPage:
|
|
121
|
+
"""Raw CDP client for a single page target."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, ws_url: str):
|
|
124
|
+
self._ws_url = ws_url
|
|
125
|
+
self._ws = None # WebSocket connection
|
|
126
|
+
self._next_id = 1
|
|
127
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
128
|
+
self._event_handlers: dict[str, list[Callable]] = {}
|
|
129
|
+
self._url = ""
|
|
130
|
+
self._reader_task: asyncio.Task | None = None
|
|
131
|
+
|
|
132
|
+
# ── Properties ────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def url(self) -> str:
|
|
136
|
+
return self._url
|
|
137
|
+
|
|
138
|
+
# ── Lifecycle ─────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
async def connect(self) -> None:
|
|
141
|
+
"""Open WebSocket, enable domains, start reader loop."""
|
|
142
|
+
self._ws = await asyncio.wait_for(
|
|
143
|
+
websockets.connect(
|
|
144
|
+
self._ws_url,
|
|
145
|
+
max_size=50 * 1024 * 1024, # 50 MB for screenshots
|
|
146
|
+
),
|
|
147
|
+
timeout=10,
|
|
148
|
+
)
|
|
149
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
150
|
+
|
|
151
|
+
# Enable core domains
|
|
152
|
+
await self.send("Page.enable")
|
|
153
|
+
await self.send("Runtime.enable")
|
|
154
|
+
await self.send("Network.enable")
|
|
155
|
+
|
|
156
|
+
await self._apply_user_agent_override()
|
|
157
|
+
|
|
158
|
+
# Track URL from frame navigations
|
|
159
|
+
self.on("Page.frameNavigated", self._on_frame_navigated)
|
|
160
|
+
|
|
161
|
+
# Get initial URL
|
|
162
|
+
try:
|
|
163
|
+
tree = await self.send("Page.getFrameTree")
|
|
164
|
+
frame = tree.get("frameTree", {}).get("frame", {})
|
|
165
|
+
self._url = frame.get("url", "")
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
async def _apply_user_agent_override(self) -> None:
|
|
170
|
+
"""Present as the Chromium we are, rather than as Electron.
|
|
171
|
+
|
|
172
|
+
Best effort: a failure here costs stealth, not function, so it must never
|
|
173
|
+
break the connection.
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
# Browser-process command, so it answers before any page is loaded.
|
|
177
|
+
version = await self.send("Browser.getVersion", timeout=5)
|
|
178
|
+
real_ua = str(version.get("userAgent") or "")
|
|
179
|
+
except Exception:
|
|
180
|
+
return
|
|
181
|
+
if not real_ua:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
user_agent = sanitize_user_agent(real_ua)
|
|
185
|
+
if user_agent == real_ua:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
hints = await self.evaluate(_HIGH_ENTROPY_EXPRESSION)
|
|
190
|
+
except Exception:
|
|
191
|
+
hints = None
|
|
192
|
+
|
|
193
|
+
params = {
|
|
194
|
+
"userAgent": user_agent,
|
|
195
|
+
"userAgentMetadata": build_user_agent_metadata(user_agent, hints),
|
|
196
|
+
}
|
|
197
|
+
try:
|
|
198
|
+
await self.send("Emulation.setUserAgentOverride", params, timeout=5)
|
|
199
|
+
except Exception:
|
|
200
|
+
# Target rejects the metadata argument; the string alone still fixes
|
|
201
|
+
# the header the edge rules match on.
|
|
202
|
+
try:
|
|
203
|
+
await self.send(
|
|
204
|
+
"Emulation.setUserAgentOverride", {"userAgent": user_agent}, timeout=5
|
|
205
|
+
)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
async def close(self) -> None:
|
|
210
|
+
"""Close WebSocket and cancel reader."""
|
|
211
|
+
if self._reader_task:
|
|
212
|
+
self._reader_task.cancel()
|
|
213
|
+
try:
|
|
214
|
+
await self._reader_task
|
|
215
|
+
except (asyncio.CancelledError, Exception):
|
|
216
|
+
pass
|
|
217
|
+
self._reader_task = None
|
|
218
|
+
|
|
219
|
+
if self._ws:
|
|
220
|
+
try:
|
|
221
|
+
await self._ws.close()
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
self._ws = None
|
|
225
|
+
|
|
226
|
+
# Reject pending requests
|
|
227
|
+
for fut in self._pending.values():
|
|
228
|
+
if not fut.done():
|
|
229
|
+
fut.set_exception(ConnectionError("CDP connection closed"))
|
|
230
|
+
self._pending.clear()
|
|
231
|
+
|
|
232
|
+
# ── High-level API ────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
async def title(self) -> str:
|
|
235
|
+
"""Get the page title via Runtime.evaluate."""
|
|
236
|
+
result = await self.evaluate("document.title")
|
|
237
|
+
return str(result) if result is not None else ""
|
|
238
|
+
|
|
239
|
+
async def goto(self, url: str, timeout: float = 30) -> None:
|
|
240
|
+
"""Navigate to a URL and wait for DOMContentLoaded."""
|
|
241
|
+
dom_loaded = asyncio.get_event_loop().create_future()
|
|
242
|
+
|
|
243
|
+
def on_dom_loaded(params: dict) -> None:
|
|
244
|
+
if not dom_loaded.done():
|
|
245
|
+
dom_loaded.set_result(True)
|
|
246
|
+
|
|
247
|
+
self.on("Page.domContentEventFired", on_dom_loaded)
|
|
248
|
+
try:
|
|
249
|
+
await self.send("Page.navigate", {"url": url})
|
|
250
|
+
await asyncio.wait_for(dom_loaded, timeout=timeout)
|
|
251
|
+
finally:
|
|
252
|
+
self.off("Page.domContentEventFired", on_dom_loaded)
|
|
253
|
+
|
|
254
|
+
async def screenshot(self) -> bytes:
|
|
255
|
+
"""Capture a PNG screenshot, returned as raw bytes."""
|
|
256
|
+
result = await self.send("Page.captureScreenshot", {"format": "png"})
|
|
257
|
+
return base64.b64decode(result["data"])
|
|
258
|
+
|
|
259
|
+
async def evaluate(self, expression: str) -> Any:
|
|
260
|
+
"""Evaluate a JS expression and return the result value."""
|
|
261
|
+
result = await self.send("Runtime.evaluate", {
|
|
262
|
+
"expression": expression,
|
|
263
|
+
"returnByValue": True,
|
|
264
|
+
"awaitPromise": True,
|
|
265
|
+
})
|
|
266
|
+
exception = result.get("exceptionDetails")
|
|
267
|
+
if exception:
|
|
268
|
+
text = exception.get("text", "")
|
|
269
|
+
desc = exception.get("exception", {}).get("description", "")
|
|
270
|
+
raise RuntimeError(f"JS error: {desc or text}")
|
|
271
|
+
return result.get("result", {}).get("value")
|
|
272
|
+
|
|
273
|
+
# ── Low-level CDP ─────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async def send(self, method: str, params: dict | None = None, timeout: float = 30) -> dict:
|
|
276
|
+
"""Send a CDP command and wait for its response."""
|
|
277
|
+
if not self._ws:
|
|
278
|
+
raise ConnectionError("Not connected")
|
|
279
|
+
|
|
280
|
+
msg_id = self._next_id
|
|
281
|
+
self._next_id += 1
|
|
282
|
+
|
|
283
|
+
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
284
|
+
self._pending[msg_id] = future
|
|
285
|
+
|
|
286
|
+
message = {"id": msg_id, "method": method}
|
|
287
|
+
if params:
|
|
288
|
+
message["params"] = params
|
|
289
|
+
|
|
290
|
+
await self._ws.send(json.dumps(message))
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
294
|
+
except asyncio.TimeoutError:
|
|
295
|
+
self._pending.pop(msg_id, None)
|
|
296
|
+
raise TimeoutError(f"CDP command {method} timed out after {timeout}s")
|
|
297
|
+
|
|
298
|
+
def on(self, event: str, handler: Callable) -> None:
|
|
299
|
+
"""Register a CDP event handler."""
|
|
300
|
+
self._event_handlers.setdefault(event, []).append(handler)
|
|
301
|
+
|
|
302
|
+
def off(self, event: str, handler: Callable) -> None:
|
|
303
|
+
"""Remove a CDP event handler."""
|
|
304
|
+
handlers = self._event_handlers.get(event, [])
|
|
305
|
+
try:
|
|
306
|
+
handlers.remove(handler)
|
|
307
|
+
except ValueError:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
# ── Internal ──────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def _on_frame_navigated(self, params: dict) -> None:
|
|
313
|
+
"""Track URL from top-level frame navigations."""
|
|
314
|
+
frame = params.get("frame", {})
|
|
315
|
+
# Only track top-level frame (no parentId)
|
|
316
|
+
if not frame.get("parentId"):
|
|
317
|
+
self._url = frame.get("url", self._url)
|
|
318
|
+
|
|
319
|
+
async def _reader_loop(self) -> None:
|
|
320
|
+
"""Read messages from WebSocket and dispatch to pending/handlers."""
|
|
321
|
+
try:
|
|
322
|
+
async for raw in self._ws:
|
|
323
|
+
try:
|
|
324
|
+
msg = json.loads(raw)
|
|
325
|
+
except json.JSONDecodeError:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
# Response to a command
|
|
329
|
+
if "id" in msg:
|
|
330
|
+
msg_id = msg["id"]
|
|
331
|
+
future = self._pending.pop(msg_id, None)
|
|
332
|
+
if future and not future.done():
|
|
333
|
+
if "error" in msg:
|
|
334
|
+
future.set_exception(
|
|
335
|
+
RuntimeError(f"CDP error: {msg['error'].get('message', str(msg['error']))}")
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
future.set_result(msg.get("result", {}))
|
|
339
|
+
|
|
340
|
+
# CDP event
|
|
341
|
+
elif "method" in msg:
|
|
342
|
+
method = msg["method"]
|
|
343
|
+
params = msg.get("params", {})
|
|
344
|
+
for handler in self._event_handlers.get(method, []):
|
|
345
|
+
try:
|
|
346
|
+
result = handler(params)
|
|
347
|
+
if asyncio.iscoroutine(result):
|
|
348
|
+
await result
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
except websockets.exceptions.ConnectionClosed:
|
|
353
|
+
pass
|
|
354
|
+
except asyncio.CancelledError:
|
|
355
|
+
raise
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
finally:
|
|
359
|
+
# Reject all pending on disconnect
|
|
360
|
+
for fut in self._pending.values():
|
|
361
|
+
if not fut.done():
|
|
362
|
+
fut.set_exception(ConnectionError("CDP WebSocket closed"))
|
|
363
|
+
self._pending.clear()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Run one hard-transition check for a WidgeX daemon state machine.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python daemon_check.py <check_file> <function_name> <context_json>
|
|
5
|
+
|
|
6
|
+
Loads <check_file> as a module, calls <function_name>, and prints a single
|
|
7
|
+
result line to stdout:
|
|
8
|
+
|
|
9
|
+
WIDGEX_CHECK:{"ok": true, "value": true}
|
|
10
|
+
WIDGEX_CHECK:{"ok": false, "error": "..."}
|
|
11
|
+
|
|
12
|
+
The function is called with the context dict if it declares a parameter, and
|
|
13
|
+
with no arguments otherwise. Its return value is coerced with bool().
|
|
14
|
+
|
|
15
|
+
This runs OUTSIDE the xactor sandbox, in its own short-lived process: the
|
|
16
|
+
sandbox blanket-denies imports from agent-supplied code, and a check script is
|
|
17
|
+
user-authored configuration rather than agent output. The caller is responsible
|
|
18
|
+
for confining <check_file> to the machine directory.
|
|
19
|
+
"""
|
|
20
|
+
import inspect
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _emit(payload):
|
|
26
|
+
sys.stdout.write("WIDGEX_CHECK:" + json.dumps(payload) + "\n")
|
|
27
|
+
sys.stdout.flush()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv):
|
|
31
|
+
if len(argv) != 4:
|
|
32
|
+
_emit({"ok": False, "error": "expected <check_file> <function> <context_json>"})
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
check_file, function_name, context_json = argv[1], argv[2], argv[3]
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
context = json.loads(context_json)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
_emit({"ok": False, "error": "bad context json: %s" % exc})
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
import importlib.util
|
|
45
|
+
|
|
46
|
+
spec = importlib.util.spec_from_file_location("widgex_daemon_check", check_file)
|
|
47
|
+
if spec is None or spec.loader is None:
|
|
48
|
+
_emit({"ok": False, "error": "cannot load %s" % check_file})
|
|
49
|
+
return 1
|
|
50
|
+
module = importlib.util.module_from_spec(spec)
|
|
51
|
+
spec.loader.exec_module(module)
|
|
52
|
+
except Exception as exc: # noqa: BLE001 - any import-time failure is a failed check
|
|
53
|
+
_emit({"ok": False, "error": "%s: %s" % (type(exc).__name__, exc)})
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
fn = getattr(module, function_name, None)
|
|
57
|
+
if fn is None or not callable(fn):
|
|
58
|
+
_emit({"ok": False, "error": "%s has no callable %s" % (check_file, function_name)})
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
takes_arg = len(inspect.signature(fn).parameters) >= 1
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
takes_arg = False
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
value = fn(context) if takes_arg else fn()
|
|
68
|
+
except Exception as exc: # noqa: BLE001 - a raising check is a false check
|
|
69
|
+
_emit({"ok": False, "error": "%s: %s" % (type(exc).__name__, exc)})
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
_emit({"ok": True, "value": bool(value)})
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websockets>=12.0
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Line-ending preservation in StdlibAPI's file tools.
|
|
2
|
+
|
|
3
|
+
There is no Python test harness in this tree — no pytest, no conftest, no
|
|
4
|
+
test_*.py, and requirements.txt lists only `websockets`. So this is deliberately
|
|
5
|
+
self-contained: stdlib `unittest`, no third-party imports, runnable directly.
|
|
6
|
+
|
|
7
|
+
cd widgex-ide/extension/python && python test_line_endings.py
|
|
8
|
+
|
|
9
|
+
What it pins down is that edit_file no longer rewrites a CRLF file's every line
|
|
10
|
+
ending as a side effect of a three-character edit, and — just as important —
|
|
11
|
+
that fixing that did not cost us any matching. Every assertion here is at the
|
|
12
|
+
byte level, because the whole bug was invisible at the string level.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
|
|
23
|
+
from xactor.stdlib_api import StdlibAPI, _dominant_eol # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(coro):
|
|
27
|
+
return asyncio.run(coro)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LineEndingCase(unittest.TestCase):
|
|
31
|
+
def setUp(self):
|
|
32
|
+
self.dir = tempfile.mkdtemp(prefix="widgex-eol-")
|
|
33
|
+
# bridge=None: the journal calls are fail-open and no-op without one.
|
|
34
|
+
self.api = StdlibAPI(cwd=self.dir, bridge=None)
|
|
35
|
+
|
|
36
|
+
def write_bytes(self, name, data: bytes) -> str:
|
|
37
|
+
path = os.path.join(self.dir, name)
|
|
38
|
+
with open(path, "wb") as f:
|
|
39
|
+
f.write(data)
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
def read_bytes(self, path) -> bytes:
|
|
43
|
+
with open(path, "rb") as f:
|
|
44
|
+
return f.read()
|
|
45
|
+
|
|
46
|
+
# -- the bug itself ----------------------------------------------
|
|
47
|
+
|
|
48
|
+
def test_crlf_file_stays_crlf_and_only_intended_bytes_change(self):
|
|
49
|
+
"""The headline case: a small edit to a big CRLF file."""
|
|
50
|
+
lines = [f"line {i} of the file" for i in range(500)]
|
|
51
|
+
lines[240] = "const target = FOO;"
|
|
52
|
+
original = ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
|
53
|
+
path = self.write_bytes("big.ts", original)
|
|
54
|
+
|
|
55
|
+
msg = run(self.api.edit_file(path, "FOO", "BAR"))
|
|
56
|
+
self.assertIn("Replaced 1", msg)
|
|
57
|
+
|
|
58
|
+
after = self.read_bytes(path)
|
|
59
|
+
# Not "roughly the same" — exactly the original with three bytes swapped.
|
|
60
|
+
self.assertEqual(after, original.replace(b"FOO", b"BAR", 1))
|
|
61
|
+
self.assertEqual(after.count(b"\r\n"), 500)
|
|
62
|
+
self.assertEqual(after.count(b"\n"), 500) # i.e. zero bare LF
|
|
63
|
+
|
|
64
|
+
def test_lf_file_stays_lf(self):
|
|
65
|
+
original = b"alpha\nbeta\ngamma\n"
|
|
66
|
+
path = self.write_bytes("plain.py", original)
|
|
67
|
+
|
|
68
|
+
run(self.api.edit_file(path, "beta", "delta"))
|
|
69
|
+
|
|
70
|
+
after = self.read_bytes(path)
|
|
71
|
+
self.assertEqual(after, b"alpha\ndelta\ngamma\n")
|
|
72
|
+
self.assertNotIn(b"\r", after)
|
|
73
|
+
|
|
74
|
+
def test_multiline_edit_in_crlf_file_writes_crlf_for_new_lines(self):
|
|
75
|
+
"""A replacement that adds lines gets the file's endings, not LF."""
|
|
76
|
+
original = b"one\r\ntwo\r\nthree\r\n"
|
|
77
|
+
path = self.write_bytes("multi.txt", original)
|
|
78
|
+
|
|
79
|
+
# An LF anchor and an LF replacement — what an agent actually sends.
|
|
80
|
+
run(self.api.edit_file(path, "one\ntwo", "one\nONE-AND-A-HALF\ntwo"))
|
|
81
|
+
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
self.read_bytes(path),
|
|
84
|
+
b"one\r\nONE-AND-A-HALF\r\ntwo\r\nthree\r\n",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# -- no matching regression --------------------------------------
|
|
88
|
+
|
|
89
|
+
def test_lf_anchor_matches_crlf_file(self):
|
|
90
|
+
"""The anchors agents actually compute are LF. They must keep matching.
|
|
91
|
+
|
|
92
|
+
read_file joins lines with LF whatever is on disk, so this is the normal
|
|
93
|
+
path on any Windows checkout, not an edge case.
|
|
94
|
+
"""
|
|
95
|
+
path = self.write_bytes("anchor.ts", b"function f() {\r\n return 1;\r\n}\r\n")
|
|
96
|
+
msg = run(self.api.edit_file(path, "function f() {\n return 1;\n}", "function f() {\n return 2;\n}"))
|
|
97
|
+
self.assertIn("Replaced 1", msg)
|
|
98
|
+
self.assertEqual(self.read_bytes(path), b"function f() {\r\n return 2;\r\n}\r\n")
|
|
99
|
+
|
|
100
|
+
def test_crlf_anchor_also_matches_crlf_file(self):
|
|
101
|
+
"""A caller who quotes the raw bytes is not punished for it either."""
|
|
102
|
+
path = self.write_bytes("anchor2.ts", b"a\r\nb\r\nc\r\n")
|
|
103
|
+
msg = run(self.api.edit_file(path, "a\r\nb", "a\r\nB"))
|
|
104
|
+
self.assertIn("Replaced 1", msg)
|
|
105
|
+
self.assertEqual(self.read_bytes(path), b"a\r\nB\r\nc\r\n")
|
|
106
|
+
|
|
107
|
+
def test_anchor_taken_from_read_file_output_matches_once_stripped(self):
|
|
108
|
+
path = self.write_bytes("numbered.py", b"import os\nimport sys\nprint(1)\n")
|
|
109
|
+
numbered = run(self.api.read_file(path))
|
|
110
|
+
# Strip the '%6d\t' prefix, as read_file's docstring now tells you to.
|
|
111
|
+
anchor = "\n".join(ln.split("\t", 1)[1] for ln in numbered.split("\n")[:2])
|
|
112
|
+
msg = run(self.api.edit_file(path, anchor, "import sys"))
|
|
113
|
+
self.assertIn("Replaced 1", msg)
|
|
114
|
+
|
|
115
|
+
def test_unstripped_read_file_output_is_diagnosed(self):
|
|
116
|
+
path = self.write_bytes("numbered2.py", b"import os\nimport sys\n")
|
|
117
|
+
numbered = run(self.api.read_file(path))
|
|
118
|
+
msg = run(self.api.edit_file(path, numbered, "x"))
|
|
119
|
+
self.assertIn("not found", msg)
|
|
120
|
+
self.assertIn("line numbers", msg)
|
|
121
|
+
|
|
122
|
+
def test_replace_all_and_not_unique_still_behave(self):
|
|
123
|
+
path = self.write_bytes("dup.txt", b"x\r\nx\r\nx\r\n")
|
|
124
|
+
msg = run(self.api.edit_file(path, "x", "y"))
|
|
125
|
+
self.assertIn("found 3 times", msg)
|
|
126
|
+
self.assertEqual(self.read_bytes(path), b"x\r\nx\r\nx\r\n") # untouched
|
|
127
|
+
|
|
128
|
+
msg = run(self.api.edit_file(path, "x", "y", replace_all=True))
|
|
129
|
+
self.assertIn("Replaced 3", msg)
|
|
130
|
+
self.assertEqual(self.read_bytes(path), b"y\r\ny\r\ny\r\n")
|
|
131
|
+
|
|
132
|
+
# -- mixed, and the degenerate shapes ----------------------------
|
|
133
|
+
|
|
134
|
+
def test_mixed_file_normalises_to_its_dominant_ending(self):
|
|
135
|
+
"""One sentence: a mixed file is rewritten to whichever ending it has
|
|
136
|
+
more of (ties to LF), so the stray minority lines change and the
|
|
137
|
+
majority do not."""
|
|
138
|
+
original = b"a\r\nb\r\nc\r\nd\ne\r\n" # 4 CRLF, 1 bare LF -> CRLF wins
|
|
139
|
+
path = self.write_bytes("mixed.txt", original)
|
|
140
|
+
run(self.api.edit_file(path, "a", "A"))
|
|
141
|
+
self.assertEqual(self.read_bytes(path), b"A\r\nb\r\nc\r\nd\r\ne\r\n")
|
|
142
|
+
|
|
143
|
+
original = b"a\nb\nc\nd\r\n" # 3 LF, 1 CRLF -> LF wins
|
|
144
|
+
path = self.write_bytes("mixed2.txt", original)
|
|
145
|
+
run(self.api.edit_file(path, "a", "A"))
|
|
146
|
+
self.assertEqual(self.read_bytes(path), b"A\nb\nc\nd\n")
|
|
147
|
+
|
|
148
|
+
def test_dominant_eol_ties_and_degenerates(self):
|
|
149
|
+
self.assertEqual(_dominant_eol(""), "\n")
|
|
150
|
+
self.assertEqual(_dominant_eol("no terminator at all"), "\n")
|
|
151
|
+
self.assertEqual(_dominant_eol("a\r\nb\n"), "\n") # 1-1 tie -> LF
|
|
152
|
+
self.assertEqual(_dominant_eol("a\r\nb\r\nc\n"), "\r\n")
|
|
153
|
+
self.assertEqual(_dominant_eol("classic\rmac\r"), "\n")
|
|
154
|
+
|
|
155
|
+
def test_single_line_no_terminator_crlf_file_untouched(self):
|
|
156
|
+
path = self.write_bytes("oneline.txt", b"just one line")
|
|
157
|
+
run(self.api.edit_file(path, "one", "1"))
|
|
158
|
+
self.assertEqual(self.read_bytes(path), b"just 1 line")
|
|
159
|
+
|
|
160
|
+
def test_lone_cr_passes_through(self):
|
|
161
|
+
"""Classic-Mac \\r is not a convention we write, and not one we destroy."""
|
|
162
|
+
path = self.write_bytes("oldmac.txt", b"a\rb\rc\n")
|
|
163
|
+
run(self.api.edit_file(path, "b", "B"))
|
|
164
|
+
self.assertEqual(self.read_bytes(path), b"a\rB\rc\n")
|
|
165
|
+
|
|
166
|
+
# -- write_file's deliberately unchanged contract -----------------
|
|
167
|
+
|
|
168
|
+
def test_write_file_creates_lf(self):
|
|
169
|
+
path = os.path.join(self.dir, "new.txt")
|
|
170
|
+
run(self.api.write_file(path, "one\ntwo\n"))
|
|
171
|
+
self.assertEqual(self.read_bytes(path), b"one\ntwo\n")
|
|
172
|
+
|
|
173
|
+
def test_write_file_writes_exactly_what_it_is_given(self):
|
|
174
|
+
path = os.path.join(self.dir, "verbatim.txt")
|
|
175
|
+
run(self.api.write_file(path, "one\r\ntwo\r\n"))
|
|
176
|
+
self.assertEqual(self.read_bytes(path), b"one\r\ntwo\r\n")
|
|
177
|
+
|
|
178
|
+
# And overwriting a CRLF file with LF content honours the caller.
|
|
179
|
+
path = self.write_bytes("was_crlf.txt", b"a\r\nb\r\n")
|
|
180
|
+
run(self.api.write_file(path, "a\nb\n"))
|
|
181
|
+
self.assertEqual(self.read_bytes(path), b"a\nb\n")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""XactorShell — Python Elementary API for humanized browser automation (Layers 1 & 2)."""
|
|
2
|
+
|
|
3
|
+
from .dom import DOMSnapshot, HybridNode, HybridDiff
|
|
4
|
+
from .api import ElementaryAPI
|
|
5
|
+
from .states import PageState, LoadingState, Observation, Transition, Step, StateRegistry
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DOMSnapshot",
|
|
9
|
+
"HybridNode",
|
|
10
|
+
"HybridDiff",
|
|
11
|
+
"ElementaryAPI",
|
|
12
|
+
"PageState",
|
|
13
|
+
"LoadingState",
|
|
14
|
+
"Observation",
|
|
15
|
+
"Transition",
|
|
16
|
+
"Step",
|
|
17
|
+
"StateRegistry",
|
|
18
|
+
]
|