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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- 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"]
|
aetherius/server/app.py
ADDED
|
@@ -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}"
|
aetherius/server/deps.py
ADDED
|
@@ -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
|