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,164 @@
|
|
|
1
|
+
"""Playwright browser lifecycle: launch, contexts, headed and headless modes, debug options.
|
|
2
|
+
|
|
3
|
+
Wraps the synchronous Playwright API. The engine (and therefore this driver) is synchronous, so the
|
|
4
|
+
sync API is the natural fit: no event loop to juggle inside the driver. Playwright is imported
|
|
5
|
+
lazily so ``import aetherius`` never pulls it in; a missing extra surfaces as a typed DependencyError.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ...core.errors import DependencyError
|
|
15
|
+
from ...stealth.fingerprint.patch import FINGERPRINT_PATCH_JS
|
|
16
|
+
from ...stealth.fingerprint.profile import get_profile
|
|
17
|
+
from ...stealth.humanizer.input import HumanInput
|
|
18
|
+
from ...stealth.policy import OFF, StealthPolicy
|
|
19
|
+
from .debug_overlay import DEBUG_OVERLAY_JS
|
|
20
|
+
from .human_actions import humanized_actions
|
|
21
|
+
|
|
22
|
+
# Slow-motion delay (ms) inserted before every action when debug is on, so a human can follow along.
|
|
23
|
+
_DEBUG_SLOW_MO_MS = 500
|
|
24
|
+
# How long the window lingers after the last step (or a failure) in debug, so it does not just
|
|
25
|
+
# flash and vanish before the final state can be read.
|
|
26
|
+
_DEBUG_LINGER_MS = 2500
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _import_playwright() -> Any:
|
|
30
|
+
"""Import the sync Playwright entry point, or raise a typed, actionable error."""
|
|
31
|
+
try:
|
|
32
|
+
from playwright.sync_api import sync_playwright
|
|
33
|
+
except ImportError as exc: # extra [browser] not installed
|
|
34
|
+
raise DependencyError(
|
|
35
|
+
"Act II (Continuum) requires Playwright. Install it with:\n"
|
|
36
|
+
' pip install "aetherius[browser]" && playwright install chromium',
|
|
37
|
+
extra="browser",
|
|
38
|
+
) from exc
|
|
39
|
+
return sync_playwright
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BrowserSession:
|
|
43
|
+
"""Owns a Playwright instance, context and page for the duration of a run.
|
|
44
|
+
|
|
45
|
+
An ephemeral context by default; a *persistent* context (reused cookies/cache/history) when a
|
|
46
|
+
profile directory is supplied. ``debug`` opens a headed window with slow-motion so a step can be
|
|
47
|
+
watched. ``stealth`` is the assembled :class:`StealthPolicy`: an active one wears a fingerprint
|
|
48
|
+
profile (context options + init patches) and exposes a :class:`HumanInput` for the driver to
|
|
49
|
+
route interactive actions through.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
debug: bool = False,
|
|
56
|
+
timeout_ms: int = 30_000,
|
|
57
|
+
profile_dir: Path | None = None,
|
|
58
|
+
stealth: StealthPolicy = OFF,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._debug = debug
|
|
61
|
+
self._timeout_ms = timeout_ms
|
|
62
|
+
self._profile_dir = profile_dir
|
|
63
|
+
self._stealth = stealth
|
|
64
|
+
self._pw: Any = None
|
|
65
|
+
self._browser: Any = None
|
|
66
|
+
self._context: Any = None
|
|
67
|
+
self._page: Any = None
|
|
68
|
+
self._human: HumanInput | None = None
|
|
69
|
+
|
|
70
|
+
def start(self) -> None:
|
|
71
|
+
"""Launch the browser and open the working page. Idempotent per session."""
|
|
72
|
+
sync_playwright = _import_playwright()
|
|
73
|
+
self._pw = sync_playwright().start()
|
|
74
|
+
|
|
75
|
+
headless = not self._debug
|
|
76
|
+
slow_mo = self._slow_mo_ms()
|
|
77
|
+
context_options = self._context_options()
|
|
78
|
+
|
|
79
|
+
if self._profile_dir is not None:
|
|
80
|
+
self._context = self._pw.chromium.launch_persistent_context(
|
|
81
|
+
str(self._profile_dir),
|
|
82
|
+
headless=headless,
|
|
83
|
+
slow_mo=slow_mo,
|
|
84
|
+
**context_options,
|
|
85
|
+
)
|
|
86
|
+
pages = self._context.pages
|
|
87
|
+
self._page = pages[0] if pages else self._context.new_page()
|
|
88
|
+
else:
|
|
89
|
+
self._browser = self._pw.chromium.launch(headless=headless, slow_mo=slow_mo)
|
|
90
|
+
self._context = self._browser.new_context(**context_options)
|
|
91
|
+
self._page = self._context.new_page()
|
|
92
|
+
|
|
93
|
+
self._context.set_default_timeout(self._timeout_ms)
|
|
94
|
+
self._apply_stealth()
|
|
95
|
+
|
|
96
|
+
if self._debug:
|
|
97
|
+
# Visible cursor + red click ripple, re-installed on every navigation.
|
|
98
|
+
self._context.add_init_script(DEBUG_OVERLAY_JS)
|
|
99
|
+
|
|
100
|
+
def _slow_mo_ms(self) -> int:
|
|
101
|
+
"""Debug slow-motion delay, but 0 whenever inputs are humanized.
|
|
102
|
+
|
|
103
|
+
slow_mo delays *every* Playwright op. That is fine for plain actions, but the humanizer
|
|
104
|
+
issues dozens of mouse.move/wheel calls per gesture and supplies its own realistic timing;
|
|
105
|
+
stacking slow_mo on top would shred each gesture into a slow, ugly stutter. So when discretion
|
|
106
|
+
humanizes inputs, the humanizer owns the pacing and slow_mo stays off, even in debug.
|
|
107
|
+
"""
|
|
108
|
+
if self._debug and not humanized_actions(self._stealth):
|
|
109
|
+
return _DEBUG_SLOW_MO_MS
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
def _context_options(self) -> dict[str, Any]:
|
|
113
|
+
"""Playwright context options for the active fingerprint profile (empty when none)."""
|
|
114
|
+
if self._stealth.fingerprint is None:
|
|
115
|
+
return {}
|
|
116
|
+
return get_profile(self._stealth.fingerprint).context_options()
|
|
117
|
+
|
|
118
|
+
def _apply_stealth(self) -> None:
|
|
119
|
+
"""Inject the fingerprint patches and, if inputs are humanized, build the HumanInput facade."""
|
|
120
|
+
if not self._stealth.is_active:
|
|
121
|
+
return
|
|
122
|
+
self._context.add_init_script(FINGERPRINT_PATCH_JS)
|
|
123
|
+
if self._stealth.fingerprint is not None:
|
|
124
|
+
self._context.add_init_script(get_profile(self._stealth.fingerprint).init_script())
|
|
125
|
+
if humanized_actions(self._stealth):
|
|
126
|
+
self._human = HumanInput(self._page, self._stealth)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def human(self) -> HumanInput | None:
|
|
130
|
+
"""The humanized input facade when discretion humanizes inputs, else ``None``."""
|
|
131
|
+
return self._human
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def page(self) -> Any:
|
|
135
|
+
"""The active Playwright page. Valid only between ``start`` and ``close``."""
|
|
136
|
+
if self._page is None:
|
|
137
|
+
raise RuntimeError("BrowserSession.page accessed before start() or after close().")
|
|
138
|
+
return self._page
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
"""Tear down page, context, browser and Playwright, tolerating partial startup.
|
|
142
|
+
|
|
143
|
+
In debug mode, linger briefly first so the final page (or the point of failure) stays on
|
|
144
|
+
screen instead of vanishing the instant the run ends.
|
|
145
|
+
"""
|
|
146
|
+
if self._debug and self._page is not None:
|
|
147
|
+
time.sleep(_DEBUG_LINGER_MS / 1000)
|
|
148
|
+
|
|
149
|
+
for closer in (
|
|
150
|
+
self._context,
|
|
151
|
+
self._browser,
|
|
152
|
+
):
|
|
153
|
+
if closer is not None:
|
|
154
|
+
try:
|
|
155
|
+
closer.close()
|
|
156
|
+
except Exception: # teardown must never mask the original run error
|
|
157
|
+
pass
|
|
158
|
+
if self._pw is not None:
|
|
159
|
+
try:
|
|
160
|
+
self._pw.stop()
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
self._page = self._context = self._browser = self._pw = None
|
|
164
|
+
self._human = None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Client-side debug overlay: a visible cursor and a red click ripple.
|
|
2
|
+
|
|
3
|
+
Injected via Playwright's ``add_init_script`` when ``options.debug`` is on, so it re-installs on
|
|
4
|
+
every navigation. It listens to the real mouse events Playwright dispatches, so the cursor tracks
|
|
5
|
+
where the bot moves and a red ripple marks every click. Purely a debug aid — never loaded otherwise,
|
|
6
|
+
and unrelated to the discretion layer (which does the opposite: hide automation).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# A self-invoking script (add_init_script evaluates verbatim, it does not call a returned function).
|
|
12
|
+
# Guarded by a window flag so repeated injection on the same document is a no-op.
|
|
13
|
+
DEBUG_OVERLAY_JS = """
|
|
14
|
+
(() => {
|
|
15
|
+
if (window.__aetheriusDebugOverlay) return;
|
|
16
|
+
window.__aetheriusDebugOverlay = true;
|
|
17
|
+
const install = () => {
|
|
18
|
+
const root = document.documentElement;
|
|
19
|
+
if (!root) return;
|
|
20
|
+
const style = document.createElement('style');
|
|
21
|
+
style.textContent = `
|
|
22
|
+
.__ae-cursor {
|
|
23
|
+
position: fixed; z-index: 2147483647; pointer-events: none;
|
|
24
|
+
width: 20px; height: 20px; margin: -10px 0 0 -10px; opacity: 0;
|
|
25
|
+
border: 2px solid rgba(224,48,48,0.95); border-radius: 50%;
|
|
26
|
+
background: rgba(224,48,48,0.20);
|
|
27
|
+
box-shadow: 0 0 8px 2px rgba(224,48,48,0.55);
|
|
28
|
+
transition: opacity 0.25s linear;
|
|
29
|
+
}
|
|
30
|
+
.__ae-ripple {
|
|
31
|
+
position: fixed; z-index: 2147483647; pointer-events: none;
|
|
32
|
+
width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;
|
|
33
|
+
background: rgba(224,48,48,0.6);
|
|
34
|
+
animation: __ae-ripple-anim 0.65s ease-out forwards;
|
|
35
|
+
}
|
|
36
|
+
@keyframes __ae-ripple-anim {
|
|
37
|
+
0% { transform: scale(1); opacity: 0.9; }
|
|
38
|
+
100% { transform: scale(6); opacity: 0; }
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
root.appendChild(style);
|
|
42
|
+
const cursor = document.createElement('div');
|
|
43
|
+
cursor.className = '__ae-cursor';
|
|
44
|
+
root.appendChild(cursor);
|
|
45
|
+
document.addEventListener('mousemove', (e) => {
|
|
46
|
+
cursor.style.opacity = '1';
|
|
47
|
+
cursor.style.left = e.clientX + 'px';
|
|
48
|
+
cursor.style.top = e.clientY + 'px';
|
|
49
|
+
}, true);
|
|
50
|
+
document.addEventListener('mousedown', (e) => {
|
|
51
|
+
const ripple = document.createElement('div');
|
|
52
|
+
ripple.className = '__ae-ripple';
|
|
53
|
+
ripple.style.left = e.clientX + 'px';
|
|
54
|
+
ripple.style.top = e.clientY + 'px';
|
|
55
|
+
root.appendChild(ripple);
|
|
56
|
+
setTimeout(() => ripple.remove(), 700);
|
|
57
|
+
}, true);
|
|
58
|
+
};
|
|
59
|
+
if (document.readyState === 'loading') {
|
|
60
|
+
document.addEventListener('DOMContentLoaded', install);
|
|
61
|
+
} else {
|
|
62
|
+
install();
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
"""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Continuum driver: follows the Blueprint literally against a real browser page.
|
|
2
|
+
|
|
3
|
+
Act II. Dispatches each step to a Playwright page operation (actions.py), a DOM read
|
|
4
|
+
(bridge.py), an artifact producer (screenshot), or an Act-agnostic handler (SharedActionsMixin).
|
|
5
|
+
Synchronous throughout, matching the engine; Playwright is imported lazily by BrowserSession.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from .._shared import SharedActionsMixin
|
|
13
|
+
from ...core.blueprint.models import StepModel
|
|
14
|
+
from ...core.errors import ActionError
|
|
15
|
+
from ...core.events.bus import EventBus
|
|
16
|
+
from ...core.events.models import EventType, RunEvent
|
|
17
|
+
from ...core.runtime.context import RunContext
|
|
18
|
+
from ...stealth.policy import StealthPolicy, build_policy
|
|
19
|
+
from ...stealth.session.store import resolve_profile_dir, run_dir
|
|
20
|
+
from .actions import PAGE_ACTIONS, _locator
|
|
21
|
+
from .bridge import evaluate, extract, wait_for
|
|
22
|
+
from .browser import BrowserSession
|
|
23
|
+
from .human_actions import HUMAN_ACTIONS, humanized_actions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ContinuumDriver(SharedActionsMixin):
|
|
27
|
+
act = "continuum"
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._session: BrowserSession | None = None
|
|
31
|
+
self._policy: StealthPolicy | None = None
|
|
32
|
+
self._humanized: frozenset[str] = frozenset()
|
|
33
|
+
|
|
34
|
+
def setup(self, ctx: RunContext) -> None:
|
|
35
|
+
opts = ctx.blueprint.options
|
|
36
|
+
session_opts = opts.session
|
|
37
|
+
profile_dir = None
|
|
38
|
+
if session_opts is not None and session_opts.persist:
|
|
39
|
+
profile_dir = resolve_profile_dir(session_opts.profile)
|
|
40
|
+
self._policy = build_policy(opts.stealth)
|
|
41
|
+
self._humanized = humanized_actions(self._policy)
|
|
42
|
+
self._session = BrowserSession(
|
|
43
|
+
debug=opts.debug,
|
|
44
|
+
timeout_ms=opts.timeout_ms or 30_000,
|
|
45
|
+
profile_dir=profile_dir,
|
|
46
|
+
stealth=self._policy,
|
|
47
|
+
)
|
|
48
|
+
self._session.start()
|
|
49
|
+
|
|
50
|
+
def teardown(self, ctx: RunContext) -> None:
|
|
51
|
+
if self._session is not None:
|
|
52
|
+
self._session.close()
|
|
53
|
+
self._session = None
|
|
54
|
+
|
|
55
|
+
def run_step(
|
|
56
|
+
self,
|
|
57
|
+
step: StepModel,
|
|
58
|
+
ctx: RunContext,
|
|
59
|
+
bus: EventBus,
|
|
60
|
+
renderer: Callable[[Any], Any],
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
if self._session is None:
|
|
63
|
+
raise ActionError("ContinuumDriver.run_step called before setup().")
|
|
64
|
+
page = self._session.page
|
|
65
|
+
params = step.extra_fields
|
|
66
|
+
|
|
67
|
+
human = self._session.human
|
|
68
|
+
if human is not None and step.action in self._humanized:
|
|
69
|
+
return HUMAN_ACTIONS[step.action](human, params, renderer)
|
|
70
|
+
|
|
71
|
+
page_action = PAGE_ACTIONS.get(step.action)
|
|
72
|
+
if page_action is not None:
|
|
73
|
+
return page_action(page, params, renderer)
|
|
74
|
+
|
|
75
|
+
match step.action:
|
|
76
|
+
case "extract":
|
|
77
|
+
return extract(page, params, renderer)
|
|
78
|
+
case "wait_for":
|
|
79
|
+
return wait_for(page, params, renderer)
|
|
80
|
+
case "evaluate":
|
|
81
|
+
return evaluate(page, params, renderer)
|
|
82
|
+
case "screenshot":
|
|
83
|
+
return self._screenshot(step, ctx, bus, renderer)
|
|
84
|
+
case "emit":
|
|
85
|
+
return self._emit(step, ctx, bus, renderer)
|
|
86
|
+
case "wait":
|
|
87
|
+
if human is not None:
|
|
88
|
+
human.park() # drift the cursor off the controls while idling, like a person
|
|
89
|
+
return self._wait(step, renderer)
|
|
90
|
+
case "set":
|
|
91
|
+
return self._set(step, renderer)
|
|
92
|
+
case "assert":
|
|
93
|
+
return self._assert(step, renderer)
|
|
94
|
+
case _:
|
|
95
|
+
raise ActionError(f"ContinuumDriver: unsupported action {step.action!r}")
|
|
96
|
+
|
|
97
|
+
def _screenshot(
|
|
98
|
+
self,
|
|
99
|
+
step: StepModel,
|
|
100
|
+
ctx: RunContext,
|
|
101
|
+
bus: EventBus,
|
|
102
|
+
renderer: Callable[[Any], Any],
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
assert self._session is not None
|
|
105
|
+
page = self._session.page
|
|
106
|
+
params = step.extra_fields
|
|
107
|
+
name = renderer(params.get("name")) or f"{step.id or 'screenshot'}.png"
|
|
108
|
+
path = run_dir(ctx.run_id) / str(name)
|
|
109
|
+
|
|
110
|
+
if params.get("selector"):
|
|
111
|
+
_locator(page, params, renderer).screenshot(path=str(path))
|
|
112
|
+
else:
|
|
113
|
+
page.screenshot(
|
|
114
|
+
path=str(path), full_page=bool(renderer(params.get("full_page", False)))
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
bus.emit(
|
|
118
|
+
RunEvent(
|
|
119
|
+
run_id=ctx.run_id,
|
|
120
|
+
type=EventType.ARTIFACT,
|
|
121
|
+
step_id=step.id,
|
|
122
|
+
message=f"screenshot: {path}",
|
|
123
|
+
level="info",
|
|
124
|
+
data={"kind": "screenshot", "path": str(path)},
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return {"path": str(path)}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Humanized variants of the interactive page actions, used when a StealthPolicy enables them.
|
|
2
|
+
|
|
3
|
+
The plain actions in ``actions.py`` stay the fast, deterministic default. When discretion is on, the
|
|
4
|
+
driver routes the interactive subset (click/hover/fill/type/scroll) here instead: same
|
|
5
|
+
``(params, render) -> dict`` contract, but the operation runs through :class:`HumanInput` so it is
|
|
6
|
+
paced and moved like a person. Locator resolution is reused from ``actions.py`` — this module owns
|
|
7
|
+
none of Playwright's selector logic, only the choice to humanize.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, Mapping
|
|
13
|
+
|
|
14
|
+
from ...stealth.humanizer.input import HumanInput
|
|
15
|
+
from ...stealth.policy import StealthPolicy
|
|
16
|
+
from .actions import Renderer, _locator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _target(human: HumanInput, params: Mapping[str, Any], render: Renderer) -> Any:
|
|
20
|
+
return _locator(human.page, params, render)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def click(human: HumanInput, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
24
|
+
human.click(_target(human, params, render))
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hover(human: HumanInput, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
29
|
+
human.hover(_target(human, params, render))
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fill(human: HumanInput, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
34
|
+
human.fill(_target(human, params, render), render(params.get("value", "")))
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def type_text(human: HumanInput, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
39
|
+
text = render(params.get("text", params.get("value", "")))
|
|
40
|
+
human.type(_target(human, params, render), text)
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scroll(human: HumanInput, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
45
|
+
# A targeted scroll is about reaching an element (kept programmatic); a bare delta is a real
|
|
46
|
+
# wheel gesture and gets the eased humanized motion.
|
|
47
|
+
if params.get("selector"):
|
|
48
|
+
_locator(human.page, params, render).scroll_into_view_if_needed()
|
|
49
|
+
else:
|
|
50
|
+
human.scroll_by(float(render(params.get("dy", 0)) or 0))
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
HumanAction = Callable[[HumanInput, Mapping[str, Any], Renderer], dict[str, Any]]
|
|
55
|
+
|
|
56
|
+
HUMAN_ACTIONS: dict[str, HumanAction] = {
|
|
57
|
+
"click": click,
|
|
58
|
+
"hover": hover,
|
|
59
|
+
"fill": fill,
|
|
60
|
+
"type": type_text,
|
|
61
|
+
"scroll": scroll,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def humanized_actions(policy: StealthPolicy) -> frozenset[str]:
|
|
66
|
+
"""Which action names *policy* should route to the humanized layer (empty when none apply)."""
|
|
67
|
+
actions: set[str] = set()
|
|
68
|
+
if policy.mouse == "gestures":
|
|
69
|
+
actions |= {"click", "hover", "fill", "type"}
|
|
70
|
+
if policy.keyboard == "human":
|
|
71
|
+
actions |= {"fill", "type"}
|
|
72
|
+
if policy.scroll == "eased":
|
|
73
|
+
actions |= {"scroll"}
|
|
74
|
+
return frozenset(actions)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Act III - Oracle: vision-guided browser automation with stealth as a first-class concern."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Oracle driver: locates targets by vision when selectors are brittle, acts through the stealth humanizer."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Turns a vision target description into concrete on-screen coordinates."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Loading and inference wrapper for the Oracle vision models."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Screenshot perception: runs the task-specific ONNX detector to find UI elements."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Act IV - Phantom: autonomous decisional agent. The heaviest Act."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Phantom driver: goal-driven execution, stealth by default, maximal resilience."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The perceive-reason-act loop that advances the agent toward its goal."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Working and task memory for the agent across steps."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Perception fusion: DOM, accessibility tree and vision merged into the planner state."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Decision policy: a vision-language planner (Claude by default, pluggable) that chooses the next action."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Act I - Vector: HTTP/API engine. The lightest Act."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Authentication strategies for Vector (Act I): NoAuth, Bearer, Basic, Cookie, CAS form-login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from parsel import Selector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class AuthStrategy(Protocol):
|
|
13
|
+
def prepare(self, client: httpx.Client) -> None:
|
|
14
|
+
"""Called once before the first request (e.g., perform a login flow)."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def apply(self, request: httpx.Request) -> httpx.Request:
|
|
18
|
+
"""Mutate or replace a request to inject auth headers/cookies. Called per-request."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoAuth:
|
|
23
|
+
def prepare(self, client: httpx.Client) -> None:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def apply(self, request: httpx.Request) -> httpx.Request:
|
|
27
|
+
return request
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BearerAuth:
|
|
31
|
+
def __init__(self, token: str) -> None:
|
|
32
|
+
self._token = token
|
|
33
|
+
|
|
34
|
+
def prepare(self, client: httpx.Client) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
def apply(self, request: httpx.Request) -> httpx.Request:
|
|
38
|
+
# httpx requests are immutable; re-build with updated headers.
|
|
39
|
+
return httpx.Request(
|
|
40
|
+
method=request.method,
|
|
41
|
+
url=request.url,
|
|
42
|
+
headers={**dict(request.headers), "Authorization": f"Bearer {self._token}"},
|
|
43
|
+
content=request.content,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BasicAuth:
|
|
48
|
+
def __init__(self, username: str, password: str) -> None:
|
|
49
|
+
self._auth = httpx.BasicAuth(username, password)
|
|
50
|
+
|
|
51
|
+
def prepare(self, client: httpx.Client) -> None:
|
|
52
|
+
client.auth = self._auth
|
|
53
|
+
|
|
54
|
+
def apply(self, request: httpx.Request) -> httpx.Request:
|
|
55
|
+
return request
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CookieAuth:
|
|
59
|
+
def __init__(self, cookies: dict[str, str]) -> None:
|
|
60
|
+
self._cookies = cookies
|
|
61
|
+
|
|
62
|
+
def prepare(self, client: httpx.Client) -> None:
|
|
63
|
+
client.cookies.update(self._cookies)
|
|
64
|
+
|
|
65
|
+
def apply(self, request: httpx.Request) -> httpx.Request:
|
|
66
|
+
return request
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CasFormLogin:
|
|
70
|
+
"""CAS-style form-login: GET login page → extract hidden fields → POST credentials → capture cookies."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
login_url: str,
|
|
75
|
+
username: str,
|
|
76
|
+
password: str,
|
|
77
|
+
username_field: str = "username",
|
|
78
|
+
password_field: str = "password",
|
|
79
|
+
) -> None:
|
|
80
|
+
self._login_url = login_url
|
|
81
|
+
self._username = username
|
|
82
|
+
self._password = password
|
|
83
|
+
self._username_field = username_field
|
|
84
|
+
self._password_field = password_field
|
|
85
|
+
|
|
86
|
+
def prepare(self, client: httpx.Client) -> None:
|
|
87
|
+
resp = client.get(self._login_url)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
form_data = _extract_form_fields(resp.content)
|
|
90
|
+
form_data[self._username_field] = self._username
|
|
91
|
+
form_data[self._password_field] = self._password
|
|
92
|
+
login_resp = client.post(self._login_url, data=form_data, follow_redirects=True)
|
|
93
|
+
login_resp.raise_for_status()
|
|
94
|
+
|
|
95
|
+
def apply(self, request: httpx.Request) -> httpx.Request:
|
|
96
|
+
return request
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extract_form_fields(html: bytes) -> dict[str, str]:
|
|
100
|
+
"""Extract all hidden input fields from the first <form> in *html*."""
|
|
101
|
+
sel = Selector(text=html.decode("utf-8", errors="replace"))
|
|
102
|
+
fields: dict[str, str] = {}
|
|
103
|
+
for inp in sel.css("form input[type=hidden]"):
|
|
104
|
+
name = inp.attrib.get("name", "")
|
|
105
|
+
value = inp.attrib.get("value", "")
|
|
106
|
+
if name:
|
|
107
|
+
fields[name] = value
|
|
108
|
+
return fields
|