aetherius 0.2.0__py3-none-any.whl

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 (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
@@ -0,0 +1,193 @@
1
+ """Gesture recorder: capture real human mouse traces to extend the stealth gesture library.
2
+
3
+ The stealth mouse humanizer replays gestures from a source-agnostic library (:mod:`aetherius.stealth
4
+ .gestures.library`); the package ships a synthetic seed so it works from day one. This recorder adds
5
+ *real* traces in the exact same neutral format: a gesture is a list of ``[dx, dy, t]`` offsets from
6
+ its start. The browser wiring captures raw pointer samples; the pure functions here segment them into
7
+ aimed gestures (the move leading up to each click) and merge them into the library file.
8
+
9
+ Segmentation is pure and unit-tested without a browser; only :func:`record_gestures` needs Playwright.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import math
16
+ import threading
17
+ import urllib.parse
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ from ..stealth.gestures import library as _library
22
+ from ..stealth.gestures.library import Point
23
+ from ._gesture_js import GESTURE_RECORDER_JS
24
+ from ._playwright import import_playwright, pump
25
+
26
+ # Reuse the library's own path as the single source of truth for where traces live.
27
+ _DEFAULT_LIBRARY_FILE = _library._DATA_FILE
28
+
29
+ # A new gesture starts after a pause this long (s); a gesture must have at least this many points and
30
+ # span at least this many pixels, to drop idle jitter and accidental micro-moves.
31
+ _PAUSE_S = 0.35
32
+ _MIN_POINTS = 5
33
+ _MIN_DISTANCE_PX = 12.0
34
+
35
+ _INSTRUCTION_HTML = """<!doctype html><html><head><meta charset="utf-8">
36
+ <style>
37
+ html,body{height:100%;margin:0;background:#0f0b1e;color:#e7e2f3;
38
+ font:16px/1.6 system-ui,sans-serif;display:flex;align-items:center;justify-content:center}
39
+ .card{max-width:32rem;text-align:center;padding:2rem}
40
+ h1{font-weight:600;letter-spacing:.02em}
41
+ b{color:#e0b64d}
42
+ </style></head><body><div class="card">
43
+ <h1>Gesture recorder</h1>
44
+ <p>Move the mouse naturally across this page and <b>click</b> in different spots, as if using a
45
+ real site. Each move up to a click is captured as one gesture.</p>
46
+ <p>When you have made a few dozen moves, <b>close this window</b> to save.</p>
47
+ </div></body></html>"""
48
+
49
+
50
+ def _instruction_url() -> str:
51
+ return "data:text/html," + urllib.parse.quote(_INSTRUCTION_HTML)
52
+
53
+
54
+ def _to_relative(segment: list[Point]) -> list[Point]:
55
+ """Rebase an absolute ``[x, y, t]`` segment onto offsets from its first point."""
56
+ x0, y0, t0 = segment[0]
57
+ return [(round(x - x0, 2), round(y - y0, 2), round(t - t0, 4)) for x, y, t in segment]
58
+
59
+
60
+ def _span(relative: list[Point]) -> float:
61
+ """Total displacement of a rebased gesture (distance of its last offset)."""
62
+ end_x, end_y, _ = relative[-1]
63
+ return math.hypot(end_x, end_y)
64
+
65
+
66
+ def segment_gestures(
67
+ samples: list[Point],
68
+ clicks: list[float],
69
+ *,
70
+ pause_s: float = _PAUSE_S,
71
+ min_points: int = _MIN_POINTS,
72
+ min_distance_px: float = _MIN_DISTANCE_PX,
73
+ ) -> list[list[Point]]:
74
+ """Split raw absolute pointer samples into aimed gestures in the neutral library format.
75
+
76
+ A gesture is the run of samples leading up to a click (the aimed move), also broken on any pause
77
+ longer than *pause_s*. Degenerate runs (too few points or too short a path) are discarded.
78
+ """
79
+ points = sorted(samples, key=lambda s: s[2])
80
+ click_times = sorted(clicks)
81
+ segments: list[list[Point]] = []
82
+ current: list[Point] = []
83
+ click_index = 0
84
+
85
+ for x, y, t in points:
86
+ if current and (t - current[-1][2]) > pause_s:
87
+ segments.append(current)
88
+ current = []
89
+ current.append((x, y, t))
90
+ while click_index < len(click_times) and click_times[click_index] <= t + 1e-6:
91
+ # Close only if the click belongs to this run; a click stranded in a pause gap before
92
+ # the current segment started belonged to an already-closed gesture, so just skip it.
93
+ if current and click_times[click_index] >= current[0][2] - 1e-6:
94
+ segments.append(current)
95
+ current = []
96
+ click_index += 1
97
+ if current:
98
+ segments.append(current)
99
+
100
+ gestures: list[list[Point]] = []
101
+ for segment in segments:
102
+ if len(segment) < min_points:
103
+ continue
104
+ relative = _to_relative(segment)
105
+ if _span(relative) >= min_distance_px:
106
+ gestures.append(relative)
107
+ return gestures
108
+
109
+
110
+ def merge_into_library(path: Path, gestures: list[list[Point]]) -> int:
111
+ """Append *gestures* to the library file at *path* without dropping what is already there.
112
+
113
+ Existing traces (synthetic seed or earlier recordings) are preserved; ``meta`` is updated to mark
114
+ that the library now contains recorded human traces. Returns the number of gestures added.
115
+ """
116
+ existing: list[Any] = []
117
+ if path.exists():
118
+ try:
119
+ data = json.loads(path.read_text(encoding="utf-8"))
120
+ existing = list(data.get("gestures", []))
121
+ except (ValueError, OSError):
122
+ existing = []
123
+
124
+ combined = existing + [[list(point) for point in gesture] for gesture in gestures]
125
+ payload = {
126
+ "meta": {
127
+ "source": "recorded-human",
128
+ "generator": "aetherius.recorder.gesture_recorder",
129
+ "count": len(combined),
130
+ "note": "Real captured traces; may include prior seed/recorded gestures (same format).",
131
+ },
132
+ "gestures": combined,
133
+ }
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ path.write_text(json.dumps(payload, indent=1), encoding="utf-8")
136
+ return len(gestures)
137
+
138
+
139
+ def record_gestures(
140
+ *,
141
+ out_path: Path | str | None = None,
142
+ on_event: Callable[[int], None] | None = None,
143
+ stop_event: threading.Event | None = None,
144
+ ) -> tuple[Path, int]:
145
+ """Capture mouse traces in a live browser, segment them, and merge them into the library.
146
+
147
+ Returns the library path written and the number of gestures added. Stops when the window closes
148
+ or *stop_event* is set.
149
+ """
150
+ samples: list[Point] = []
151
+ clicks: list[float] = []
152
+
153
+ def on_binding(_source: dict[str, Any], payload: str) -> None:
154
+ try:
155
+ data = json.loads(payload)
156
+ except (TypeError, ValueError):
157
+ return
158
+ for move in data.get("moves", []):
159
+ samples.append((float(move[0]), float(move[1]), float(move[2])))
160
+ if data.get("click") is not None:
161
+ clicks.append(float(data["click"]))
162
+ if on_event is not None:
163
+ on_event(len(samples))
164
+
165
+ sync_playwright = import_playwright()
166
+ pw = sync_playwright().start()
167
+ browser = context = None
168
+ disconnected = threading.Event()
169
+ try:
170
+ browser = pw.chromium.launch(headless=False)
171
+ browser.on("disconnected", lambda: disconnected.set())
172
+ context = browser.new_context()
173
+ context.expose_binding("__aetherius_gesture", on_binding)
174
+ context.add_init_script(GESTURE_RECORDER_JS)
175
+ page = context.new_page()
176
+ page.goto(_instruction_url())
177
+ pump(context, disconnected, stop_event)
178
+ finally:
179
+ for closer in (context, browser):
180
+ if closer is not None:
181
+ try:
182
+ closer.close()
183
+ except Exception:
184
+ pass
185
+ try:
186
+ pw.stop()
187
+ except Exception:
188
+ pass
189
+
190
+ gestures = segment_gestures(samples, clicks)
191
+ target = Path(out_path) if out_path is not None else _DEFAULT_LIBRARY_FILE
192
+ added = merge_into_library(target, gestures)
193
+ return target, added
@@ -0,0 +1,83 @@
1
+ """Robust selector synthesis: prefer data-testid, aria and text before fragile css paths.
2
+
3
+ The in-page capture script (``_capture_js.py``) does the DOM work: for a target element it forms
4
+ candidate selector strings (escaping with the browser's ``CSS.escape``) and, crucially, tests each
5
+ for *uniqueness* on the live page (``querySelectorAll(sel).length === 1``). This module owns the
6
+ policy that is worth testing on its own: given those candidates, which one to keep, in what order,
7
+ and when to fall back to a positional css path. Keeping the ranking here — pure, browser-free — means
8
+ it runs in the base test suite while the DOM facts it consumes are produced by the browser.
9
+
10
+ A selector survives a page far better when it is anchored on intent (a test id, a name, a label,
11
+ visible text) than on a brittle ``div > div:nth-child(3) > span`` chain, so intent-based strategies
12
+ are ranked first and the positional path is only ever the last resort.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ # Strategy names, in the order we trust them. First unique candidate wins; css_path is the fallback.
20
+ # data-testid/-test/-cy/-qa are added by developers precisely as stable hooks, so they rank highest;
21
+ # a positional css path is the most fragile and ranks below everything intent-based.
22
+ _PRIORITY: tuple[str, ...] = ("testid", "id", "href", "name", "aria", "role", "class", "text")
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Candidate:
27
+ """One selector the page offers for an element, tagged with its strategy and uniqueness.
28
+
29
+ ``unique`` is measured in-page against the live DOM; a non-unique candidate is skipped because a
30
+ Blueprint action would hit Playwright's strict mode on an ambiguous target.
31
+ """
32
+
33
+ strategy: str
34
+ selector: str
35
+ selector_type: str # "css" | "text" | "xpath"
36
+ unique: bool
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ElementDescriptor:
41
+ """Everything the capture script observed about a target element.
42
+
43
+ ``candidates`` feed selector synthesis; ``name``/``field_type``/``autocomplete`` feed the
44
+ credential heuristic in the blueprint recorder. One descriptor, two consumers, no duplication.
45
+ """
46
+
47
+ tag: str
48
+ css_path: str
49
+ candidates: tuple[Candidate, ...] = ()
50
+ text: str | None = None
51
+ name: str | None = None
52
+ field_type: str | None = None
53
+ autocomplete: str | None = None
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class SelectorChoice:
58
+ """The chosen selector and how to interpret it (``css`` is the Blueprint default)."""
59
+
60
+ selector: str
61
+ selector_type: str = "css"
62
+ strategy: str = "css_path"
63
+
64
+
65
+ def synthesize(descriptor: ElementDescriptor) -> SelectorChoice:
66
+ """Pick the most robust selector for *descriptor*.
67
+
68
+ Walks the trusted strategies in order and returns the first candidate that is unique on the
69
+ page; if none qualifies, falls back to the positional css path (or the bare tag if even that is
70
+ missing, so a caller always gets something usable rather than an empty selector).
71
+ """
72
+ by_strategy: dict[str, Candidate] = {}
73
+ for candidate in descriptor.candidates:
74
+ # Keep the first candidate seen per strategy: the capture script emits them best-first.
75
+ by_strategy.setdefault(candidate.strategy, candidate)
76
+
77
+ for strategy in _PRIORITY:
78
+ chosen = by_strategy.get(strategy)
79
+ if chosen is not None and chosen.unique:
80
+ return SelectorChoice(chosen.selector, chosen.selector_type, strategy)
81
+
82
+ fallback = descriptor.css_path or descriptor.tag
83
+ return SelectorChoice(fallback, "css", "css_path")
@@ -0,0 +1,101 @@
1
+ """Generic recording session: owns the live browser, driven by an Act-specific backend.
2
+
3
+ This is the shared shell every recorder reuses — launch, context, page, the synchronous pump, and the
4
+ seams a backend needs (expose a binding, hook new pages, stream a description, end the session). It
5
+ holds no Act logic: what to inject and how to interpret captures is the backend's job (:mod:`.base`).
6
+ Synchronous throughout (runs on the caller's thread); Playwright is imported lazily.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from typing import Any, Callable
13
+
14
+ from ._playwright import import_playwright, pump
15
+ from .base import RecorderBackend, RecordingResult
16
+
17
+ # A backend streams human-readable descriptions of what it captures; the Console/CLI render them.
18
+ NotifyCallback = Callable[[str], None]
19
+ PageHook = Callable[[Any], None]
20
+
21
+
22
+ class RecordingSession:
23
+ """Owns a visible browser for a demonstration and returns the backend's RecordingResult.
24
+
25
+ Stops when the user closes the window, when ``stop_event`` is set (the Console's Stop button), or
26
+ when the backend calls :meth:`finish` (e.g. the overlay's Finish button).
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ start_url: str,
32
+ backend: RecorderBackend,
33
+ *,
34
+ on_event: NotifyCallback | None = None,
35
+ stop_event: threading.Event | None = None,
36
+ ) -> None:
37
+ self._start_url = start_url
38
+ self._backend = backend
39
+ self._on_event = on_event
40
+ self._stop = stop_event
41
+ self._done = threading.Event()
42
+ self._context: Any = None
43
+ self._page_hooks: list[PageHook] = []
44
+
45
+ def record(self) -> RecordingResult:
46
+ """Launch the browser, let the backend capture until the session ends, return its result."""
47
+ sync_playwright = import_playwright()
48
+ pw = sync_playwright().start()
49
+ browser = None
50
+ disconnected = threading.Event()
51
+ try:
52
+ browser = pw.chromium.launch(headless=False)
53
+ browser.on("disconnected", lambda: disconnected.set())
54
+ self._context = browser.new_context()
55
+ for script in self._backend.init_scripts():
56
+ self._context.add_init_script(script)
57
+ self._backend.attach(
58
+ self
59
+ ) # backend registers bindings + page hooks now that ctx exists
60
+ self._context.on("page", self._wire_page)
61
+
62
+ page = self._context.new_page()
63
+ self._wire_page(page)
64
+ page.goto(self._start_url)
65
+
66
+ pump(self._context, disconnected, self._stop, self._done)
67
+ finally:
68
+ for closer in (self._context, browser):
69
+ if closer is not None:
70
+ try:
71
+ closer.close()
72
+ except Exception: # teardown must never mask what was already captured
73
+ pass
74
+ try:
75
+ pw.stop()
76
+ except Exception:
77
+ pass
78
+ self._context = None
79
+ return self._backend.result()
80
+
81
+ # ── backend-facing seams ──────────────────────────────────────────────────
82
+ def expose(self, name: str, handler: Callable[..., Any]) -> None:
83
+ """Expose a page binding ``window[name]`` that calls *handler* (context-wide, all pages)."""
84
+ self._context.expose_binding(name, handler)
85
+
86
+ def on_new_page(self, hook: PageHook) -> None:
87
+ """Register *hook* to run on the first page and any later popup/tab."""
88
+ self._page_hooks.append(hook)
89
+
90
+ def notify(self, description: str) -> None:
91
+ """Stream one human-readable description of a captured item (never raises)."""
92
+ if self._on_event is not None:
93
+ self._on_event(description)
94
+
95
+ def finish(self) -> None:
96
+ """End the session from the backend (e.g. the overlay Finish control message)."""
97
+ self._done.set()
98
+
99
+ def _wire_page(self, page: Any) -> None:
100
+ for hook in self._page_hooks:
101
+ hook(page)
@@ -0,0 +1,158 @@
1
+ """Vector recorder backend: turn captured API calls into an ``http.request`` Blueprint (Act I).
2
+
3
+ The in-page script (:mod:`._vector_js`) observes network calls and lets the user pick a request and
4
+ the JSON fields to extract; this backend transforms those picks into ``http.request`` steps with
5
+ JSONPath extraction (the shape [`acts/vector/driver.py`] runs). Picks on the same request coalesce
6
+ into one step. Auth-bearing headers become ``{{ secrets.x }}`` — never stored literally — the network
7
+ analogue of the DOM recorder's credentials→secrets.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+ from urllib.parse import unquote_plus
15
+
16
+ from ._names import ident, unique
17
+ from ._vector_js import VECTOR_JS
18
+ from .base import RecordingResult, register_backend
19
+ from .session import RecordingSession
20
+
21
+ # Header names (lowercased) that carry credentials: kept as secret placeholders, never in the file.
22
+ _SENSITIVE_HEADERS = frozenset(
23
+ {"authorization", "cookie", "x-api-key", "api-key", "x-auth-token", "x-csrf-token"}
24
+ )
25
+
26
+
27
+ def _parse_body(body: str | None) -> tuple[str | None, Any]:
28
+ """Classify a request body: JSON object/array, url-encoded form, or unknown (omitted)."""
29
+ if not body:
30
+ return None, None
31
+ text = body.strip()
32
+ if text[:1] in "{[":
33
+ try:
34
+ return "json", json.loads(text)
35
+ except ValueError:
36
+ pass
37
+ if "=" in text:
38
+ form: dict[str, str] = {}
39
+ for pair in text.split("&"):
40
+ key, _, value = pair.partition("=")
41
+ form[unquote_plus(key)] = unquote_plus(value)
42
+ return "form", form
43
+ return None, None
44
+
45
+
46
+ def _clean_headers(raw: dict[str, Any], secrets: list[str]) -> dict[str, str]:
47
+ """Keep Content-Type; turn auth headers into secret placeholders; drop browser noise."""
48
+ clean: dict[str, str] = {}
49
+ for name, value in raw.items():
50
+ lowered = str(name).lower()
51
+ if lowered in _SENSITIVE_HEADERS:
52
+ secret = unique(ident(lowered.replace("-", "_"), fallback="token"), set(secrets))
53
+ secrets.append(secret)
54
+ clean[name] = f"{{{{ secrets.{secret} }}}}"
55
+ elif lowered == "content-type":
56
+ clean[name] = str(value)
57
+ return clean
58
+
59
+
60
+ def _build_request_step(
61
+ request_id: str, request: dict[str, Any], secrets: list[str]
62
+ ) -> dict[str, Any]:
63
+ """Reproduce a captured request as an http.request step (method/url/headers/body/expect)."""
64
+ step: dict[str, Any] = {
65
+ "id": request_id,
66
+ "action": "http.request",
67
+ "method": str(request.get("method", "GET")),
68
+ "url": str(request.get("url", "")),
69
+ }
70
+ headers = _clean_headers(request.get("headers") or {}, secrets)
71
+ if headers:
72
+ step["headers"] = headers
73
+ body_kind, body_value = _parse_body(request.get("body"))
74
+ if body_kind == "json":
75
+ step["json"] = body_value
76
+ elif body_kind == "form":
77
+ step["form"] = body_value
78
+ step["expect"] = {"status": int(request.get("status") or 200)}
79
+ return step
80
+
81
+
82
+ def _extract_spec(extract: dict[str, Any]) -> dict[str, Any]:
83
+ """One extract entry: JSONPath, plus a fields map for records."""
84
+ spec: dict[str, Any] = {"from": "json", "path": str(extract.get("path", "$"))}
85
+ fields = extract.get("fields")
86
+ if fields:
87
+ spec["fields"] = {str(k): str(v) for k, v in fields.items()}
88
+ return spec
89
+
90
+
91
+ def transform(events: list[dict[str, Any]]) -> RecordingResult:
92
+ """Turn captured ``http_request`` picks into http.request steps, secrets and outputs."""
93
+ steps: list[dict[str, Any]] = []
94
+ secrets: list[str] = []
95
+ outputs: dict[str, Any] = {}
96
+ by_request: dict[tuple[str, str, str], dict[str, Any]] = {}
97
+ output_names: set[str] = set()
98
+ sequence = 0
99
+
100
+ for event in events:
101
+ request = event.get("request") or {}
102
+ extract = event.get("extract") or {}
103
+ key = (str(request.get("method")), str(request.get("url")), str(request.get("body")))
104
+ step = by_request.get(key)
105
+ if step is None:
106
+ sequence += 1
107
+ request_id = "req" if sequence == 1 else f"req_{sequence}"
108
+ step = _build_request_step(request_id, request, secrets)
109
+ by_request[key] = step
110
+ steps.append(step)
111
+ name = unique(ident(extract.get("name"), fallback="data"), output_names)
112
+ output_names.add(name)
113
+ step.setdefault("extract", {})[name] = _extract_spec(extract)
114
+ outputs[name] = f"{{{{ steps.{step['id']}.{name} }}}}"
115
+
116
+ return RecordingResult(steps=steps, secrets=secrets, inputs={}, outputs=outputs)
117
+
118
+
119
+ def _describe(event: dict[str, Any]) -> str:
120
+ request = event.get("request") or {}
121
+ return f"{request.get('method', 'GET')} {request.get('url', '')} -> {event.get('extract', {}).get('name', '?')}"
122
+
123
+
124
+ class VectorRecorder:
125
+ """Records API calls (via a browser) into a ``vector`` Blueprint."""
126
+
127
+ act = "vector"
128
+
129
+ def __init__(self) -> None:
130
+ self._events: list[dict[str, Any]] = []
131
+ self._session: RecordingSession | None = None
132
+
133
+ def init_scripts(self) -> list[str]:
134
+ return [VECTOR_JS]
135
+
136
+ def attach(self, session: RecordingSession) -> None:
137
+ self._session = session
138
+ session.expose("__aetherius_capture", self._on_binding)
139
+
140
+ def result(self) -> RecordingResult:
141
+ return transform(self._events)
142
+
143
+ def _on_binding(self, _source: dict[str, Any], payload: str) -> None:
144
+ try:
145
+ data = json.loads(payload)
146
+ except (TypeError, ValueError):
147
+ return
148
+ if data.get("kind") == "finish":
149
+ if self._session is not None:
150
+ self._session.finish()
151
+ return
152
+ if data.get("kind") == "http_request":
153
+ self._events.append(data)
154
+ if self._session is not None:
155
+ self._session.notify(_describe(data))
156
+
157
+
158
+ register_backend("vector", lambda **_: VectorRecorder())
@@ -0,0 +1,12 @@
1
+ """The local daemon exposing Aetherius to any language over HTTP and WebSocket.
2
+
3
+ Import stays light (FastAPI/uvicorn are base dependencies): ``create_app`` builds the ASGI app and
4
+ ``DaemonConfig`` carries its bind address and optional token. Nothing here pulls an Act extra.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .app import create_app
10
+ from .config import DaemonConfig
11
+
12
+ __all__ = ["create_app", "DaemonConfig"]
@@ -0,0 +1,37 @@
1
+ """FastAPI application factory wiring routes, config and the run manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import FastAPI
8
+
9
+ from ..version import __version__
10
+ from .config import DaemonConfig
11
+ from .jobs import RunManager
12
+ from .routes import blueprints, recorder, runs, stream
13
+
14
+
15
+ def create_app(config: DaemonConfig | None = None) -> FastAPI:
16
+ """Build the daemon application. One :class:`RunManager` lives for the app's lifetime."""
17
+ config = config or DaemonConfig()
18
+
19
+ app = FastAPI(
20
+ title="Aetherius Daemon",
21
+ version=__version__,
22
+ summary="Local HTTP + WebSocket gateway to the Aetherius engine.",
23
+ )
24
+ app.state.config = config
25
+ app.state.manager = RunManager()
26
+
27
+ app.include_router(runs.router)
28
+ app.include_router(blueprints.router)
29
+ app.include_router(recorder.router)
30
+ app.include_router(stream.router)
31
+
32
+ @app.get("/health", tags=["meta"])
33
+ async def health() -> dict[str, Any]:
34
+ """Unauthenticated readiness probe used by the SDK and the Console when spawning the daemon."""
35
+ return {"status": "ok", "version": __version__}
36
+
37
+ return app
@@ -0,0 +1,25 @@
1
+ """Daemon configuration (bind address, auth token, limits).
2
+
3
+ Resolved from the environment (prefix ``AETHERIUS_DAEMON_``) with CLI overrides. The daemon binds to
4
+ loopback by default: it exposes the engine to local processes only, never the network. A token is
5
+ optional; when set, every HTTP request and WebSocket upgrade must present it as a bearer credential.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class DaemonConfig(BaseSettings):
14
+ """Runtime configuration for the local daemon."""
15
+
16
+ model_config = SettingsConfigDict(env_prefix="AETHERIUS_DAEMON_", extra="ignore")
17
+
18
+ host: str = "127.0.0.1"
19
+ port: int = 8787
20
+ token: str | None = None
21
+
22
+ @property
23
+ def base_url(self) -> str:
24
+ """The HTTP address the daemon serves on."""
25
+ return f"http://{self.host}:{self.port}"
@@ -0,0 +1,50 @@
1
+ """Shared FastAPI dependencies: run-manager access and optional bearer authentication.
2
+
3
+ The manager and config live on ``app.state`` (set by the application factory) so routers stay
4
+ decoupled from construction. Authentication is a no-op unless a token is configured, in which case
5
+ every ``/v1`` request must present it as ``Authorization: Bearer <token>``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastapi import HTTPException, status
11
+ from starlette.requests import HTTPConnection
12
+
13
+ from .config import DaemonConfig
14
+ from .jobs import RunManager
15
+
16
+
17
+ def get_config(conn: HTTPConnection) -> DaemonConfig:
18
+ """Injected for both HTTP requests and WebSocket connections (their common base)."""
19
+ config = conn.app.state.config
20
+ assert isinstance(config, DaemonConfig)
21
+ return config
22
+
23
+
24
+ def get_manager(conn: HTTPConnection) -> RunManager:
25
+ manager = conn.app.state.manager
26
+ assert isinstance(manager, RunManager)
27
+ return manager
28
+
29
+
30
+ def require_auth(request: HTTPConnection) -> None:
31
+ """Reject the request when a token is configured and the bearer credential does not match."""
32
+ config = get_config(request)
33
+ if config.token is None:
34
+ return
35
+ if request.headers.get("Authorization") != f"Bearer {config.token}":
36
+ raise HTTPException(
37
+ status_code=status.HTTP_401_UNAUTHORIZED,
38
+ detail="Invalid or missing bearer token.",
39
+ headers={"WWW-Authenticate": "Bearer"},
40
+ )
41
+
42
+
43
+ def websocket_authorized(config: DaemonConfig, header: str | None, query_token: str | None) -> bool:
44
+ """WebSocket auth: browsers cannot set headers on the upgrade, so a ``?token=`` query is also
45
+ accepted. Returns True when no token is configured."""
46
+ if config.token is None:
47
+ return True
48
+ if header == f"Bearer {config.token}":
49
+ return True
50
+ return query_token == config.token