@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.
Files changed (131) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4685 -0
  4. package/package.json +30 -4
  5. package/provider-clis.json +1211 -0
  6. package/python/XACTOR.md +146 -0
  7. package/python/browser_actor_host.py +456 -0
  8. package/python/cdp_page.py +363 -0
  9. package/python/harness_check.py +77 -0
  10. package/python/requirements.txt +1 -0
  11. package/python/test_line_endings.py +185 -0
  12. package/python/xactor/__init__.py +18 -0
  13. package/python/xactor/__main__.py +226 -0
  14. package/python/xactor/api.py +882 -0
  15. package/python/xactor/bridge.py +784 -0
  16. package/python/xactor/dom.py +117 -0
  17. package/python/xactor/man/add_card.md +89 -0
  18. package/python/xactor/man/agent-vs-fork.md +108 -0
  19. package/python/xactor/man/agent.md +333 -0
  20. package/python/xactor/man/ask_model.md +110 -0
  21. package/python/xactor/man/ask_reference.md +56 -0
  22. package/python/xactor/man/ask_user.md +103 -0
  23. package/python/xactor/man/attach_app.md +595 -0
  24. package/python/xactor/man/attach_ide.md +65 -0
  25. package/python/xactor/man/attach_ide_surface.md +31 -0
  26. package/python/xactor/man/await_user.md +214 -0
  27. package/python/xactor/man/bash.md +142 -0
  28. package/python/xactor/man/browser-driver.md +137 -0
  29. package/python/xactor/man/browser_activity.md +53 -0
  30. package/python/xactor/man/browser_attach.md +84 -0
  31. package/python/xactor/man/browser_close_tab.md +50 -0
  32. package/python/xactor/man/browser_detach.md +50 -0
  33. package/python/xactor/man/browser_exec.md +66 -0
  34. package/python/xactor/man/browser_focus_tab.md +47 -0
  35. package/python/xactor/man/browser_hybrid.md +47 -0
  36. package/python/xactor/man/browser_open.md +50 -0
  37. package/python/xactor/man/browser_open_tab.md +58 -0
  38. package/python/xactor/man/browser_plumbing.md +107 -0
  39. package/python/xactor/man/browser_screenshot.md +56 -0
  40. package/python/xactor/man/browser_snapshot.md +52 -0
  41. package/python/xactor/man/browser_tabs.md +67 -0
  42. package/python/xactor/man/bulletin.md +88 -0
  43. package/python/xactor/man/capture_page.md +54 -0
  44. package/python/xactor/man/cd.md +64 -0
  45. package/python/xactor/man/chess_move.md +53 -0
  46. package/python/xactor/man/children.md +89 -0
  47. package/python/xactor/man/close_task.md +14 -0
  48. package/python/xactor/man/compact.md +75 -0
  49. package/python/xactor/man/drive.md +140 -0
  50. package/python/xactor/man/drive_space.md +72 -0
  51. package/python/xactor/man/driving.md +168 -0
  52. package/python/xactor/man/edit_file.md +112 -0
  53. package/python/xactor/man/finding.md +194 -0
  54. package/python/xactor/man/fork_agent.md +45 -0
  55. package/python/xactor/man/glob.md +83 -0
  56. package/python/xactor/man/grep.md +115 -0
  57. package/python/xactor/man/harness.md +165 -0
  58. package/python/xactor/man/harness_check.md +70 -0
  59. package/python/xactor/man/harness_format.md +257 -0
  60. package/python/xactor/man/highlight.md +66 -0
  61. package/python/xactor/man/instruct.md +111 -0
  62. package/python/xactor/man/ls.md +63 -0
  63. package/python/xactor/man/man.md +86 -0
  64. package/python/xactor/man/merge_wiz.md +40 -0
  65. package/python/xactor/man/notebook.md +98 -0
  66. package/python/xactor/man/overview.md +89 -0
  67. package/python/xactor/man/permissions.md +137 -0
  68. package/python/xactor/man/phase.md +150 -0
  69. package/python/xactor/man/powershell.md +74 -0
  70. package/python/xactor/man/profiles.md +60 -0
  71. package/python/xactor/man/project_drive.md +49 -0
  72. package/python/xactor/man/read_bulletin.md +89 -0
  73. package/python/xactor/man/read_file.md +90 -0
  74. package/python/xactor/man/relay.md +216 -0
  75. package/python/xactor/man/reply.md +72 -0
  76. package/python/xactor/man/reply_message.md +78 -0
  77. package/python/xactor/man/report_bug.md +118 -0
  78. package/python/xactor/man/request_answer.md +142 -0
  79. package/python/xactor/man/request_merge_decisions.md +121 -0
  80. package/python/xactor/man/request_operator_action.md +152 -0
  81. package/python/xactor/man/request_review.md +122 -0
  82. package/python/xactor/man/request_user_action.md +126 -0
  83. package/python/xactor/man/return_card.md +71 -0
  84. package/python/xactor/man/run.md +99 -0
  85. package/python/xactor/man/sandbox.md +144 -0
  86. package/python/xactor/man/session_fork.md +74 -0
  87. package/python/xactor/man/session_harness.md +109 -0
  88. package/python/xactor/man/session_join.md +102 -0
  89. package/python/xactor/man/session_land.md +73 -0
  90. package/python/xactor/man/session_nudge.md +73 -0
  91. package/python/xactor/man/session_rest.md +73 -0
  92. package/python/xactor/man/session_scope.md +84 -0
  93. package/python/xactor/man/session_terminate.md +58 -0
  94. package/python/xactor/man/session_token.md +78 -0
  95. package/python/xactor/man/sessions.md +93 -0
  96. package/python/xactor/man/set_harness.md +15 -0
  97. package/python/xactor/man/spawn_swarm.md +80 -0
  98. package/python/xactor/man/submit_evidence.md +54 -0
  99. package/python/xactor/man/swarm_brief.md +30 -0
  100. package/python/xactor/man/task_status.md +82 -0
  101. package/python/xactor/man/tasks.md +47 -0
  102. package/python/xactor/man/todo_write.md +104 -0
  103. package/python/xactor/man/update_card.md +70 -0
  104. package/python/xactor/man/use_profile.md +90 -0
  105. package/python/xactor/man/use_widget.md +82 -0
  106. package/python/xactor/man/wait_for_task.md +105 -0
  107. package/python/xactor/man/widget-model.md +284 -0
  108. package/python/xactor/man/widgets.md +59 -0
  109. package/python/xactor/man/write_file.md +76 -0
  110. package/python/xactor/man/writing-a-widget.md +142 -0
  111. package/python/xactor/man/xylon.md +120 -0
  112. package/python/xactor/man/xylon_blame.md +80 -0
  113. package/python/xactor/man/xylon_read.md +131 -0
  114. package/python/xactor/man/xylon_search.md +125 -0
  115. package/python/xactor/manual.py +195 -0
  116. package/python/xactor/parent_watch.py +115 -0
  117. package/python/xactor/plan_mode.py +350 -0
  118. package/python/xactor/sandbox.py +856 -0
  119. package/python/xactor/seals.py +438 -0
  120. package/python/xactor/shell_hints.py +210 -0
  121. package/python/xactor/states/__init__.py +13 -0
  122. package/python/xactor/states/base.py +523 -0
  123. package/python/xactor/states/registry.py +274 -0
  124. package/python/xactor/stdlib_api.py +6345 -0
  125. package/python/xactor/widgets/__init__.py +1 -0
  126. package/python/xactor/widgets/github_tokens.py +314 -0
  127. package/python/xactor/widgets/google_aistudio.py +365 -0
  128. package/settings-schema.json +403 -0
  129. package/viewer/index.css +160 -0
  130. package/viewer/index.js +15 -0
  131. 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
+ ]