@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,438 @@
1
+ """Offline WidgeX marker enforcement for Python-only file operations.
2
+
3
+ The shell owns this implementation because reads and searches never cross the
4
+ TypeScript bridge. A bridge lookup would both add an RPC per read and disappear
5
+ when permissions are bypassed, precisely where an absolute organisational rule
6
+ must remain present.
7
+
8
+ Three layers live here: `check_call` judges an api call by its arguments (the
9
+ file tools, plus `drive`/`navigate`/`browser_open_tab` URLs and `aux_context`);
10
+ `check_event` is the audit hook's backstop for every file any other api body
11
+ opens; and the index remembers every seal it has seen, so a rewrite that drops
12
+ one does not unseal the file. Shell *commands* are judged in TypeScript
13
+ (`sealGate.ts`), which is where the permission request for them goes, and
14
+ their output is screened there too (`sealScreen.ts`).
15
+ """
16
+
17
+ import contextlib
18
+ import contextvars
19
+ import hashlib
20
+ import os
21
+ import re
22
+ import time
23
+ from dataclasses import dataclass
24
+ from typing import Any, Iterator
25
+
26
+ _PREFIX = "WGX" + "SEAL"
27
+ SEAL_DOC_DIRECTIVE = _PREFIX + "-DOC"
28
+ SEAL_OP_ORDER = "RWXCM"
29
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
30
+ _CHUNK_DOMAIN = _PREFIX + "/v1/model\n"
31
+ _CHUNK_BYTES = 5
32
+ _CHUNK_SYMBOLS = 8
33
+ _MAX_SEAL_BYTES = 7 + 1 + 5 + 1 + (64 * 8) + 63
34
+ _SCAN_CHUNK_BYTES = 256 * 1024
35
+ _OVERLAP_BYTES = _MAX_SEAL_BYTES
36
+ _HUGE_FILE_BYTES = 64 * 1024 * 1024
37
+ _HUGE_HEAD_BYTES = 8 * 1024 * 1024
38
+ _HUGE_TAIL_BYTES = 1 * 1024 * 1024
39
+ _RECENT_WRITE_SECONDS = 2
40
+ _MAX_ENTRIES = 5000
41
+ _CROCKFORD_CLASS = "[0-9A-HJKMNP-TV-Z]"
42
+
43
+ # Assembly keeps this engine's grammar from becoming a live marker if the
44
+ # quarantine directive is ever damaged. The scanner must not make its own
45
+ # enforcement source uneditable merely because it explains the syntax.
46
+ _SEAL_RE = re.compile(
47
+ rf"(?<![0-9A-Za-z]){_PREFIX}-([{SEAL_OP_ORDER}]{{1,5}})"
48
+ rf"(?:-({_CROCKFORD_CLASS}{{{_CHUNK_SYMBOLS}}}(?:-{_CROCKFORD_CLASS}{{{_CHUNK_SYMBOLS}}}){{0,63}}))?"
49
+ rf"(?![-0-9A-Za-z])"
50
+ )
51
+ _DOC_RE = re.compile(rf"(?<![0-9A-Za-z]){SEAL_DOC_DIRECTIVE}(?![-0-9A-Za-z])")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ParsedSeal:
56
+ raw: str
57
+ source: str
58
+ index: int
59
+ denied_ops: frozenset[str]
60
+ chunks: tuple[str, ...]
61
+
62
+
63
+ def has_seal_doc_directive(text: str) -> bool:
64
+ return bool(_DOC_RE.search(text))
65
+
66
+
67
+ def normalize(ops: Any) -> list[str]:
68
+ seen: set[str] = set()
69
+ for raw in ops:
70
+ for char in str(raw).upper():
71
+ if char in SEAL_OP_ORDER:
72
+ seen.add(char)
73
+ return [op for op in SEAL_OP_ORDER if op in seen]
74
+
75
+
76
+ def model_chunk(key: str) -> str:
77
+ digest = hashlib.sha256((_CHUNK_DOMAIN + key).encode("utf-8")).digest()[:_CHUNK_BYTES]
78
+ value = 0
79
+ bits = 0
80
+ out: list[str] = []
81
+ for byte in digest:
82
+ value = (value << 8) | byte
83
+ bits += 8
84
+ while bits >= 5:
85
+ out.append(_CROCKFORD[(value >> (bits - 5)) & 31])
86
+ bits -= 5
87
+ if bits:
88
+ out.append(_CROCKFORD[(value << (5 - bits)) & 31])
89
+ return "".join(out)
90
+
91
+
92
+ def _find(text: str, source: str) -> list[ParsedSeal]:
93
+ if not text or _PREFIX not in text or has_seal_doc_directive(text):
94
+ return []
95
+ return [ParsedSeal(match.group(0), source, match.start(), frozenset(normalize([match.group(1)])),
96
+ tuple(match.group(2).split("-")) if match.group(2) else ())
97
+ for match in _SEAL_RE.finditer(text)]
98
+
99
+
100
+ def find_seals_in_text(text: str) -> list[ParsedSeal]:
101
+ return _find(text, "text")
102
+
103
+
104
+ def find_seals_in_path(file_path: str) -> list[ParsedSeal]:
105
+ return _find(str(file_path).replace("\\", "/"), "path")
106
+
107
+
108
+ def seal_candidate_keys(model: Any) -> list[str]:
109
+ if isinstance(model, dict):
110
+ provider, model_id = str(model.get("providerId", "")), str(model.get("modelId", ""))
111
+ elif isinstance(model, (tuple, list)) and len(model) == 2:
112
+ provider, model_id = str(model[0]), str(model[1])
113
+ else:
114
+ value = str(model)
115
+ provider, sep, model_id = value.partition("/")
116
+ if not sep:
117
+ provider, model_id = "", value
118
+ bare = re.sub(r"-(?:\d{4}-\d{2}-\d{2}|\d{8})$", "", model_id)
119
+ return list(dict.fromkeys([f"{provider}/{model_id}", model_id, f"{provider}/{bare}", bare, f"{provider}/*"]))
120
+
121
+
122
+ def seal_applies_to(seal: ParsedSeal, model: Any) -> bool:
123
+ return not seal.chunks or bool(set(seal.chunks) & {model_chunk(key) for key in seal_candidate_keys(model)})
124
+
125
+
126
+ def denied_ops_for(seals: list[ParsedSeal], model: Any) -> set[str]:
127
+ denied: set[str] = set()
128
+ for seal in seals:
129
+ if seal_applies_to(seal, model):
130
+ denied.update(seal.denied_ops)
131
+ return denied
132
+
133
+
134
+ def _true_case(path: str) -> str:
135
+ """`path` with each existing component spelled as the filesystem spells it.
136
+
137
+ Only matters where names are case-insensitive: there a lowercased spelling
138
+ of a sealed folder's name lands inside that folder, and only the real,
139
+ uppercase spelling parses as a seal. `realpath` does not correct case on
140
+ macOS, so each component is matched against its directory there. Windows'
141
+ realpath already answers with the real spelling of the longest existing
142
+ prefix, and elsewhere names are case-sensitive.
143
+ """
144
+ if os.sys.platform != "darwin":
145
+ return path
146
+ drive, rest = os.path.splitdrive(path)
147
+ parts = [p for p in re.split(r"[\\/]+", rest) if p]
148
+ current = drive + os.sep if (drive or rest.startswith(("/", "\\"))) else ""
149
+ for i, part in enumerate(parts):
150
+ try:
151
+ names = os.listdir(current or ".")
152
+ except OSError:
153
+ return os.path.join(current, *parts[i:]) if current else path
154
+ match = next((n for n in names if n.lower() == part.lower()), None)
155
+ current = os.path.join(current, match if match is not None else part)
156
+ if match is None:
157
+ return os.path.join(current, *parts[i + 1:])
158
+ return current or path
159
+
160
+
161
+ @dataclass
162
+ class _CacheEntry:
163
+ mtime_ns: int
164
+ ctime_ns: int
165
+ size: int
166
+ marks: list[ParsedSeal]
167
+ truncated: bool
168
+
169
+
170
+ class _Index:
171
+ def __init__(self) -> None:
172
+ self.cache: dict[str, _CacheEntry] = {}
173
+ # Every body seal ever seen on a path. A rescan that finds fewer keeps
174
+ # the missing ones: `sed -i` on the marker line, or appending the DOC
175
+ # directive, must not unseal a file. Mirrors `SealIndex.seen` in TS.
176
+ self.seen: dict[str, list[ParsedSeal]] = {}
177
+
178
+ def _remember(self, key: str, marks: list[ParsedSeal]) -> list[ParsedSeal]:
179
+ before = self.seen.get(key, [])
180
+ if not before and not marks:
181
+ return marks
182
+ now = {mark.raw for mark in marks}
183
+ kept = [mark for mark in before if mark.raw not in now]
184
+ merged = [*marks, *kept]
185
+ self.seen[key] = merged
186
+ return merged
187
+
188
+ def forget(self, file_path: str | None = None) -> None:
189
+ if file_path is None:
190
+ self.cache.clear()
191
+ self.seen.clear()
192
+ return
193
+ resolved = os.path.abspath(file_path)
194
+ key = os.path.normcase(resolved) if os.name == "nt" or os.sys.platform == "darwin" else resolved
195
+ self.cache.pop(key, None)
196
+ self.seen.pop(key, None)
197
+
198
+ def _path_marks(self, path: str) -> list[ParsedSeal]:
199
+ marks = find_seals_in_path(path)
200
+ try:
201
+ real = _true_case(os.path.realpath(path))
202
+ except OSError:
203
+ real = path
204
+ if real == path:
205
+ return marks
206
+ seen = {mark.raw for mark in marks}
207
+ marks.extend(mark for mark in find_seals_in_path(real) if mark.raw not in seen)
208
+ return marks
209
+
210
+ def _scan(self, path: str, size: int) -> tuple[list[ParsedSeal], bool]:
211
+ huge = size > _HUGE_FILE_BYTES
212
+ marks: list[ParsedSeal] = []
213
+ seen: set[str] = set()
214
+ quarantined = False
215
+ try:
216
+ with open(path, "rb") as file:
217
+ def scan_span(start: int, end: int) -> None:
218
+ nonlocal quarantined
219
+ file.seek(start)
220
+ carry = b""
221
+ while file.tell() < end:
222
+ data = file.read(min(_SCAN_CHUNK_BYTES, end - file.tell()))
223
+ if not data:
224
+ break
225
+ text = (carry + data).decode("latin1")
226
+ quarantined = quarantined or has_seal_doc_directive(text)
227
+ for mark in find_seals_in_text(text):
228
+ if mark.raw not in seen:
229
+ seen.add(mark.raw)
230
+ marks.append(mark)
231
+ carry = (carry + data)[-_OVERLAP_BYTES:]
232
+ if huge:
233
+ scan_span(0, _HUGE_HEAD_BYTES)
234
+ scan_span(max(_HUGE_HEAD_BYTES, size - _HUGE_TAIL_BYTES), size)
235
+ else:
236
+ scan_span(0, size)
237
+ except OSError:
238
+ return [], False
239
+ return ([] if quarantined else marks), huge
240
+
241
+ def marks_for_path(self, file_path: str) -> tuple[list[ParsedSeal], list[ParsedSeal], bool]:
242
+ resolved = os.path.abspath(file_path)
243
+ path_marks = self._path_marks(resolved)
244
+ try:
245
+ stat = os.stat(resolved)
246
+ except OSError:
247
+ return path_marks, [], False
248
+ if not os.path.isfile(resolved):
249
+ return path_marks, [], False
250
+ key = os.path.normcase(resolved) if os.name == "nt" or os.sys.platform == "darwin" else resolved
251
+ hit = self.cache.get(key)
252
+ if (hit and hit.mtime_ns == stat.st_mtime_ns and hit.ctime_ns == stat.st_ctime_ns
253
+ and hit.size == stat.st_size and time.time() - stat.st_mtime > _RECENT_WRITE_SECONDS):
254
+ return path_marks, hit.marks, hit.truncated
255
+ marks, truncated = self._scan(resolved, stat.st_size)
256
+ marks = self._remember(key, marks)
257
+ self.cache.pop(key, None)
258
+ self.cache[key] = _CacheEntry(stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size, marks, truncated)
259
+ while len(self.cache) > _MAX_ENTRIES:
260
+ self.cache.pop(next(iter(self.cache)))
261
+ return path_marks, marks, truncated
262
+
263
+ def all_marks(self, file_path: str) -> list[ParsedSeal]:
264
+ path, content, _ = self.marks_for_path(file_path)
265
+ return [*path, *content]
266
+
267
+
268
+ _INDEX = _Index()
269
+
270
+
271
+ class SealDenied(PermissionError):
272
+ pass
273
+
274
+
275
+ def _model_name(model_id: Any) -> str:
276
+ if isinstance(model_id, dict):
277
+ return f"{model_id.get('providerId', '')}/{model_id.get('modelId', '')}"
278
+ return str(model_id) if model_id else "an unidentified model"
279
+
280
+
281
+ def check_path(op: str, path: str, model_id: Any) -> None:
282
+ marks = _INDEX.all_marks(path)
283
+ if not marks:
284
+ return
285
+ denied = denied_ops_for(marks, model_id) if model_id else {op for mark in marks for op in mark.denied_ops}
286
+ if op in denied:
287
+ culprit = next((mark for mark in marks if op in mark.denied_ops), marks[0])
288
+ raise SealDenied(f"WidgeX Seal — `{culprit.raw}` on `{path}` forbids {op} for `{_model_name(model_id)}`. "
289
+ "This is absolute: no permission mode bypasses it and no approval unlocks it.")
290
+
291
+
292
+ def forget(file_path: str | None = None) -> None:
293
+ """Drop what is remembered about one path, or all of them. Re-scan seals."""
294
+ _INDEX.forget(file_path)
295
+
296
+
297
+ # ── The backstop: every file an api body opens ──────────────────────────
298
+ #
299
+ # `check_call` judges a call by its arguments, which works for the four calls
300
+ # whose arguments *are* the file. Everything else that reads a file on the
301
+ # caller's behalf — an aux_context loader, an importer, a capture — named its
302
+ # path in some other argument or none at all, and was never checked. So the
303
+ # audit hook asks here about every `open`, `os.remove`, `os.rename` and
304
+ # `shutil.copyfile` an api body performs, judged against the model the exec
305
+ # was stamped with.
306
+ #
307
+ # The four that check themselves are exempt, because they legitimately open
308
+ # files the seal forbids *returning*: `write_file` reads the old bytes to judge
309
+ # integrity, `edit_file` has to read what it edits, and `grep` skips sealed
310
+ # files after looking at them. Their own checks are the stricter ones.
311
+
312
+ SELF_CHECKED = frozenset({"read_file", "write_file", "edit_file", "grep", "glob", "ls"})
313
+
314
+ _acting_model: contextvars.ContextVar[Any] = contextvars.ContextVar("seal_acting_model", default=None)
315
+ _backstop: contextvars.ContextVar[bool] = contextvars.ContextVar("seal_backstop", default=False)
316
+
317
+
318
+ @contextlib.contextmanager
319
+ def acting(model_id: Any, backstop: bool) -> Iterator[None]:
320
+ """Run an api body as `model_id`, with or without the open() backstop."""
321
+ model_token = _acting_model.set(model_id)
322
+ backstop_token = _backstop.set(backstop)
323
+ try:
324
+ yield
325
+ finally:
326
+ _backstop.reset(backstop_token)
327
+ _acting_model.reset(model_token)
328
+
329
+
330
+ @contextlib.contextmanager
331
+ def internal() -> Iterator[None]:
332
+ """Opens the shell makes for itself, not for the caller — exempt."""
333
+ token = _backstop.set(False)
334
+ try:
335
+ yield
336
+ finally:
337
+ _backstop.reset(token)
338
+
339
+
340
+ def backstop_active() -> bool:
341
+ return _backstop.get()
342
+
343
+
344
+ def check_event(op: str, path: str) -> None:
345
+ """The audit hook's question: may this api body do `op` to `path`?
346
+
347
+ Raises `SealDenied`. Called from inside the hook with its re-entrancy guard
348
+ held, so the scan's own `open` calls are not audited back into here.
349
+ """
350
+ if not _backstop.get():
351
+ return
352
+ check_path(op, path, _acting_model.get())
353
+
354
+
355
+ def _integrity(before: str, after: str) -> ParsedSeal | None:
356
+ had = find_seals_in_text(before)
357
+ kept = {seal.raw for seal in find_seals_in_text(after)}
358
+ return next((seal for seal in had if seal.raw not in kept), None)
359
+
360
+
361
+ def local_path_of_url(url: Any) -> str | None:
362
+ """The file a `file://` URL or a bare absolute path names, or None.
363
+
364
+ `api.drive` accepts both, and a browser opened on a sealed file renders its
365
+ bytes into a page every page verb can read back.
366
+ """
367
+ if not isinstance(url, str) or not url.strip():
368
+ return None
369
+ text = url.strip()
370
+ # Every spelling Chromium accepts: file:///x, file:/x, and view-source: of either.
371
+ if text.lower().startswith("view-source:"):
372
+ text = text[len("view-source:"):]
373
+ if text.lower().startswith("file:"):
374
+ from urllib.parse import unquote, urlparse
375
+ parsed = urlparse(text)
376
+ path = unquote(parsed.path)
377
+ if parsed.netloc and parsed.netloc.lower() != "localhost":
378
+ path = f"//{parsed.netloc}{path}" # a UNC share
379
+ if re.match(r"^/[A-Za-z]:", path):
380
+ path = path[1:]
381
+ return path or None
382
+ if os.path.isabs(text):
383
+ return text
384
+ return None
385
+
386
+
387
+ # Calls that hand a file to something that will show it to a model, under an
388
+ # argument that is not `path`. Each is a read of that file.
389
+ _URL_ARGS = {"drive": "url", "browser_open_tab": "url", "navigate": "url"}
390
+ _AUX_CALLS = frozenset({"session_fork", "agent"})
391
+
392
+
393
+ def check_call(name: str, args: dict[str, Any], model_id: Any) -> None:
394
+ if name in _URL_ARGS:
395
+ local = local_path_of_url(args.get(_URL_ARGS[name]))
396
+ if local:
397
+ check_path("R", local, model_id)
398
+ return
399
+ if name in _AUX_CALLS:
400
+ aux = args.get("aux_context")
401
+ if isinstance(aux, str) and aux.strip():
402
+ check_path("R", aux, model_id)
403
+ return
404
+ op = {"read_file": "R", "write_file": "W", "edit_file": "W"}.get(name)
405
+ if not op:
406
+ return
407
+ path = next((args[key] for key in ("file_path", "path", "notebook_path", "filePath")
408
+ if isinstance(args.get(key), str) and args[key]), None)
409
+ if not path:
410
+ return
411
+ check_path(op, path, model_id)
412
+ # An edit is also a read: finding `old_string`, and saying whether it was
413
+ # found, is an oracle on the file. A read seal refuses it (as `sealGate`).
414
+ if name == "edit_file":
415
+ check_path("R", path, model_id)
416
+ if op != "W":
417
+ return
418
+ # Integrity is model-independent: a second, unsealed model must not be able
419
+ # to remove a marker for the model it serves, or scoped seals are theatre.
420
+ try:
421
+ with internal(), open(path, "r", encoding="utf-8", errors="replace") as file:
422
+ before = file.read()
423
+ except OSError:
424
+ return
425
+ after = args.get("content") if name == "write_file" else None
426
+ # Bytes are judged as the text they decode to. `write_file` accepts bytes
427
+ # now, and a marker removed through bytes is removed all the same — left
428
+ # undecoded, this check would simply have been skipped.
429
+ if isinstance(after, (bytes, bytearray)):
430
+ after = bytes(after).decode("utf-8", errors="replace")
431
+ if name == "edit_file" and isinstance(args.get("old_string"), str) and isinstance(args.get("new_string"), str):
432
+ old, new = args["old_string"], args["new_string"]
433
+ after = before.replace(old, new) if args.get("replace_all") else before.replace(old, new, 1)
434
+ if isinstance(after, str):
435
+ lost = _integrity(before, after)
436
+ if lost:
437
+ raise SealDenied(f"WidgeX Seal — this edit would remove `{lost.raw}` from `{path}`. "
438
+ "A seal cannot delete itself, whatever operations it lists.")
@@ -0,0 +1,210 @@
1
+ """What went wrong between the layers, said at the layer that did it.
2
+
3
+ A command on its way to a program crosses the agent's Python string, then
4
+ `bash -c`, then often a second interpreter. Each failure below was read out of
5
+ real transcripts, where it cost a round trip or more because the agent could
6
+ see neither what bash received nor that bash was the one who changed it. The
7
+ error it did see always came from the last layer, and named the symptom.
8
+
9
+ Two kinds of rule:
10
+
11
+ * **Definite** — the command is wrong whatever happened when it ran. bash
12
+ *will* expand `$_` inside double quotes; an unquoted `C:\\Users` *will* lose
13
+ its backslashes. These are noted every time, because the run that exits 0 is
14
+ exactly the one that does damage quietly: a `Stop-Process` that stopped
15
+ nothing, a `type` that read nothing.
16
+ * **On failure** — worth saying only once something has gone wrong, because
17
+ the same construct is often right: a missing tool, a backslash Python
18
+ consumed on purpose.
19
+
20
+ Pure functions over strings, so they can be tested without a shell.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Any
27
+
28
+ # ── Definite ───────────────────────────────────────────────────────────
29
+
30
+ _POWERSHELL = re.compile(r"\b(?:powershell|pwsh)(?:\.exe)?\b", re.IGNORECASE)
31
+ _PS_VAR = re.compile(r"(?<!\\)\$(?:_|[A-Za-z][\w:]*|\{|\()")
32
+
33
+
34
+ def _double_quoted_after(command: str, start: int) -> str | None:
35
+ """The first bash double-quoted string after `start`, or None.
36
+
37
+ Tracks single quotes, inside which a double quote means nothing, and `\\"`
38
+ inside double quotes, which does not close them.
39
+ """
40
+ i, n = start, len(command)
41
+ in_single = False
42
+ while i < n:
43
+ c = command[i]
44
+ if in_single:
45
+ if c == "'":
46
+ in_single = False
47
+ elif c == "'":
48
+ in_single = True
49
+ elif c in ";|&\n":
50
+ return None # the invocation ended without a double-quoted argument
51
+ elif c == '"':
52
+ j = i + 1
53
+ while j < n:
54
+ if command[j] == "\\":
55
+ j += 2
56
+ continue
57
+ if command[j] == '"':
58
+ return command[i + 1:j]
59
+ j += 1
60
+ return command[i + 1:]
61
+ i += 1
62
+ return None
63
+
64
+
65
+ def powershell_dollar(command: str) -> str | None:
66
+ """`$` meant for PowerShell, sitting where bash expands it first."""
67
+ for m in _POWERSHELL.finditer(command):
68
+ body = _double_quoted_after(command, m.end())
69
+ if body is None:
70
+ continue
71
+ names = sorted({v.group(0) for v in _PS_VAR.finditer(body)})
72
+ if names:
73
+ shown = ", ".join(f"`{n}`" for n in names[:4])
74
+ return (
75
+ f"bash expanded {shown} inside the double quotes before PowerShell "
76
+ "ran — `$_` becomes bash's last argument and other names become "
77
+ "empty — so PowerShell did not get the script you wrote. Use "
78
+ "api.powershell(script), which passes it untouched."
79
+ )
80
+ return None
81
+
82
+
83
+ _DRIVE_BACKSLASH = re.compile(r"[A-Za-z]:\\[^\s'\"]")
84
+
85
+
86
+ def unquoted_windows_path(command: str) -> str | None:
87
+ """A `C:\\...` path outside quotes, whose backslashes bash removes."""
88
+ in_single = in_double = False
89
+ i, n = 0, len(command)
90
+ while i < n:
91
+ c = command[i]
92
+ if in_single:
93
+ if c == "'":
94
+ in_single = False
95
+ elif in_double:
96
+ if c == "\\":
97
+ i += 2
98
+ continue
99
+ if c == '"':
100
+ in_double = False
101
+ elif c == "'":
102
+ in_single = True
103
+ elif c == '"':
104
+ in_double = True
105
+ elif c == "\\":
106
+ i += 2
107
+ continue
108
+ else:
109
+ m = _DRIVE_BACKSLASH.match(command, i)
110
+ if m and (i == 0 or not command[i - 1].isalnum()):
111
+ end = i
112
+ while end < n and not command[end].isspace() and command[end] not in ";|&":
113
+ end += 1
114
+ path = command[i:end]
115
+ mangled = path.replace("\\", "")
116
+ return (
117
+ f"bash removes unquoted backslashes, so `{path}` reached the "
118
+ f"program as `{mangled}`. Write `{path.replace(chr(92), '/')}`, "
119
+ "or quote it."
120
+ )
121
+ i += 1
122
+ return None
123
+
124
+
125
+ # ── On failure ─────────────────────────────────────────────────────────
126
+
127
+ _NOT_FOUND = re.compile(r"(?:^|\n)(?:[^\n:]*: )?(?:line \d+: )?([\w.+-]+): command not found")
128
+
129
+ # Tools an agent expects and Git Bash on Windows does not have, with the way
130
+ # to get the same thing. Only names seen missing in transcripts.
131
+ _MISSING_TOOLS = {
132
+ "pkill": "stop processes with api.powershell(\"Stop-Process -Name <name>\")",
133
+ "pgrep": "list processes with api.powershell(\"Get-Process <name>\")",
134
+ "killall": "stop processes with api.powershell(\"Stop-Process -Name <name>\")",
135
+ "python3": "on Windows the interpreter is `python`",
136
+ "pwsh": "Windows PowerShell is `powershell`, or call api.powershell(script)",
137
+ "dig": "use `nslookup`, or api.powershell(\"Resolve-DnsName <name>\")",
138
+ }
139
+
140
+
141
+ def missing_tool(stderr: str) -> str | None:
142
+ for m in _NOT_FOUND.finditer(stderr or ""):
143
+ advice = _MISSING_TOOLS.get(m.group(1))
144
+ if advice:
145
+ return f"`{m.group(1)}` is not available in this shell: {advice}."
146
+ return None
147
+
148
+
149
+ _FAILURE_TEXT = re.compile(
150
+ r"SyntaxError|syntax error|unexpected (?:token|EOF|end of file)|unterminated|"
151
+ r"is not recognized|No such file|not found|ParserError|Missing (?:expression|closing)",
152
+ )
153
+
154
+
155
+ def looks_failed(result: dict[str, Any]) -> bool:
156
+ """Whether a shell result shows any sign of having gone wrong."""
157
+ code = result.get("exit_code")
158
+ if isinstance(code, int) and code != 0:
159
+ return True
160
+ return bool(_FAILURE_TEXT.search(result.get("stderr") or ""))
161
+
162
+
163
+ def hints_for(command: str, result: dict[str, Any], literal_note: str | None = None) -> list[str]:
164
+ """Every note this command and its result call for, most specific first."""
165
+ notes = [h for h in (powershell_dollar(command), unquoted_windows_path(command)) if h]
166
+ if "background_id" in result:
167
+ return notes
168
+ if looks_failed(result):
169
+ if literal_note:
170
+ notes.append(literal_note)
171
+ tool = missing_tool(result.get("stderr") or "")
172
+ if tool:
173
+ notes.append(tool)
174
+ return notes
175
+
176
+
177
+ # ── The literal lint, computed when the exec is compiled ───────────────
178
+
179
+ _ESCAPE = re.compile(r"\\(\\|'|\"|n|t)")
180
+ _SHOWN = {"\\": "`\\\\` became `\\`", "'": "`\\'` became `'`", '"': '`\\"` became `"`',
181
+ "n": "`\\n` became a line break", "t": "`\\t` became a tab"}
182
+
183
+
184
+ def literal_note(segment: str, value: str | None, line: int, call: str) -> str | None:
185
+ """The note for one non-raw string literal passed to a shell call.
186
+
187
+ `segment` is the literal's source text, prefix and quotes included; `value`
188
+ is what Python made of it, when it is a plain constant.
189
+ """
190
+ prefix = re.match(r"[A-Za-z]*", segment).group(0).lower()
191
+ if "r" in prefix:
192
+ return None
193
+ escapes = []
194
+ for m in _ESCAPE.finditer(segment):
195
+ shown = _SHOWN[m.group(1)]
196
+ if shown not in escapes:
197
+ escapes.append(shown)
198
+ if not escapes:
199
+ return None
200
+ target = "PowerShell" if call == "powershell" else ("the program" if call == "run" else "bash")
201
+ note = (
202
+ f"line {line}: the string passed to api.{call} is not raw, so Python "
203
+ f"processed it before {target} saw it — {', '.join(escapes[:3])}."
204
+ )
205
+ if value is not None:
206
+ received = value.replace("\n", "⏎")
207
+ if len(received) > 160:
208
+ received = received[:157] + "…"
209
+ note += f" {target[0].upper() + target[1:]} received: {received}"
210
+ return note + ' Write it as r"…" to pass backslashes through as written.'
@@ -0,0 +1,13 @@
1
+ """Layer 2 — State machine infrastructure for browser automation."""
2
+
3
+ from .base import PageState, LoadingState, Observation, Transition, Step
4
+ from .registry import StateRegistry
5
+
6
+ __all__ = [
7
+ "PageState",
8
+ "LoadingState",
9
+ "Observation",
10
+ "Transition",
11
+ "Step",
12
+ "StateRegistry",
13
+ ]