superbrowser-sdk 2.0.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.
- super_browser/__init__.py +17 -0
- super_browser/agent/__init__.py +37 -0
- super_browser/agent/config.py +18 -0
- super_browser/agent/debug.py +201 -0
- super_browser/agent/delegator.py +145 -0
- super_browser/agent/facade.py +1216 -0
- super_browser/agent/llm/__init__.py +7 -0
- super_browser/agent/llm/anthropic_client.py +326 -0
- super_browser/agent/llm/browser_transport.py +352 -0
- super_browser/agent/llm/budget_aware.py +195 -0
- super_browser/agent/llm/factory.py +89 -0
- super_browser/agent/llm/openai_client.py +409 -0
- super_browser/agent/llm/protocol.py +73 -0
- super_browser/agent/loop.py +647 -0
- super_browser/agent/loop_detector.py +75 -0
- super_browser/agent/plugins.py +38 -0
- super_browser/agent/registry.py +218 -0
- super_browser/agent/router.py +99 -0
- super_browser/agent/structured_logging.py +76 -0
- super_browser/agent/types.py +184 -0
- super_browser/behavioral/__init__.py +36 -0
- super_browser/behavioral/bezier.py +64 -0
- super_browser/behavioral/dwell.py +162 -0
- super_browser/behavioral/fitts.py +35 -0
- super_browser/behavioral/gauss.py +100 -0
- super_browser/behavioral/keyboard.py +183 -0
- super_browser/behavioral/mouse.py +198 -0
- super_browser/behavioral/navigation.py +144 -0
- super_browser/behavioral/orchestrator.py +221 -0
- super_browser/behavioral/prng.py +27 -0
- super_browser/behavioral/qwerty.py +163 -0
- super_browser/behavioral/scroll.py +113 -0
- super_browser/behavioral/session_seed.py +85 -0
- super_browser/behavioral/types.py +62 -0
- super_browser/browser/__init__.py +15 -0
- super_browser/browser/backends/__init__.py +43 -0
- super_browser/browser/backends/cdp_backend.py +613 -0
- super_browser/browser/backends/patchright_backend.py +351 -0
- super_browser/browser/backends/playwright_backend.py +368 -0
- super_browser/browser/backends/selenium_backend.py +567 -0
- super_browser/browser/cdp.py +241 -0
- super_browser/browser/cloak_backend.py +162 -0
- super_browser/browser/cloud.py +265 -0
- super_browser/browser/config.py +48 -0
- super_browser/browser/discovery.py +101 -0
- super_browser/browser/engine.py +326 -0
- super_browser/browser/fetch.py +384 -0
- super_browser/browser/injectors/__init__.py +46 -0
- super_browser/browser/injectors/bidi_injector.py +39 -0
- super_browser/browser/injectors/cdp_injector.py +83 -0
- super_browser/browser/injectors/page_injector.py +71 -0
- super_browser/browser/page.py +96 -0
- super_browser/browser/session.py +295 -0
- super_browser/browser/shutdown.py +75 -0
- super_browser/browser/tabs.py +140 -0
- super_browser/budget/__init__.py +40 -0
- super_browser/budget/cascade.py +142 -0
- super_browser/budget/client.py +132 -0
- super_browser/budget/compressor.py +218 -0
- super_browser/budget/cost_estimator.py +67 -0
- super_browser/budget/credential_pool.py +282 -0
- super_browser/budget/governor.py +279 -0
- super_browser/budget/types.py +227 -0
- super_browser/cli/__init__.py +214 -0
- super_browser/cli/commands.py +235 -0
- super_browser/cli/interactive.py +85 -0
- super_browser/cli/script.py +266 -0
- super_browser/cli.py +279 -0
- super_browser/config.py +479 -0
- super_browser/events/__init__.py +31 -0
- super_browser/events/bus.py +110 -0
- super_browser/events/types.py +42 -0
- super_browser/interaction/__init__.py +32 -0
- super_browser/interaction/cache.py +180 -0
- super_browser/interaction/controller.py +648 -0
- super_browser/interaction/decorator.py +41 -0
- super_browser/interaction/presets.py +117 -0
- super_browser/interaction/recovery.py +48 -0
- super_browser/interaction/snapshot.py +153 -0
- super_browser/interaction/types.py +135 -0
- super_browser/interaction/vision.py +56 -0
- super_browser/memory/__init__.py +6 -0
- super_browser/memory/integration.py +85 -0
- super_browser/memory/store.py +241 -0
- super_browser/memory/types.py +57 -0
- super_browser/plugins/__init__.py +10 -0
- super_browser/plugins/decorators.py +33 -0
- super_browser/plugins/hooks.py +28 -0
- super_browser/py.typed +0 -0
- super_browser/recording/__init__.py +19 -0
- super_browser/recording/persistence.py +49 -0
- super_browser/recording/recorder.py +189 -0
- super_browser/recording/replayer.py +218 -0
- super_browser/recording/report.py +123 -0
- super_browser/recording/types.py +124 -0
- super_browser/recovery/__init__.py +67 -0
- super_browser/recovery/checkpoint.py +317 -0
- super_browser/recovery/classifier.py +235 -0
- super_browser/recovery/coordinator.py +240 -0
- super_browser/recovery/event_bus.py +67 -0
- super_browser/recovery/format_validator.py +172 -0
- super_browser/recovery/reflection.py +109 -0
- super_browser/recovery/retry_tracker.py +81 -0
- super_browser/recovery/session_recovery.py +248 -0
- super_browser/recovery/types.py +178 -0
- super_browser/recovery/watchdogs.py +251 -0
- super_browser/results/__init__.py +54 -0
- super_browser/results/output.py +154 -0
- super_browser/results/typed.py +165 -0
- super_browser/results/types.py +361 -0
- super_browser/results/validation.py +126 -0
- super_browser/security/__init__.py +75 -0
- super_browser/security/action_redaction.py +127 -0
- super_browser/security/approval.py +130 -0
- super_browser/security/credential_vault.py +203 -0
- super_browser/security/domain_filter.py +56 -0
- super_browser/security/gate.py +114 -0
- super_browser/security/injection.py +162 -0
- super_browser/security/manager.py +185 -0
- super_browser/security/policy.py +69 -0
- super_browser/security/redactor.py +151 -0
- super_browser/security/types.py +215 -0
- super_browser/session/__init__.py +1 -0
- super_browser/session/proxy.py +153 -0
- super_browser/skills/__init__.py +31 -0
- super_browser/skills/activation.py +38 -0
- super_browser/skills/markdown.py +109 -0
- super_browser/skills/registry.py +335 -0
- super_browser/skills/types.py +123 -0
- super_browser/stealth/__init__.py +94 -0
- super_browser/stealth/action_policy.py +88 -0
- super_browser/stealth/captcha.py +318 -0
- super_browser/stealth/challenges/__init__.py +41 -0
- super_browser/stealth/challenges/cache.py +293 -0
- super_browser/stealth/challenges/pow.py +242 -0
- super_browser/stealth/challenges/turnstile.py +259 -0
- super_browser/stealth/consistency/__init__.py +28 -0
- super_browser/stealth/consistency/dag.py +142 -0
- super_browser/stealth/consistency/derive.py +282 -0
- super_browser/stealth/consistency/errors.py +40 -0
- super_browser/stealth/consistency/inject.py +429 -0
- super_browser/stealth/consistency/inject_delivery.py +283 -0
- super_browser/stealth/consistency/matrix.py +106 -0
- super_browser/stealth/consistency/prng.py +115 -0
- super_browser/stealth/consistency/rule.py +64 -0
- super_browser/stealth/consistency/rules/__init__.py +40 -0
- super_browser/stealth/consistency/rules/audio.py +37 -0
- super_browser/stealth/consistency/rules/behavior.py +142 -0
- super_browser/stealth/consistency/rules/fonts.py +49 -0
- super_browser/stealth/consistency/rules/gpu.py +116 -0
- super_browser/stealth/consistency/rules/locale.py +66 -0
- super_browser/stealth/consistency/rules/navigator.py +70 -0
- super_browser/stealth/consistency/rules/screen.py +87 -0
- super_browser/stealth/consistency/rules/user_agent.py +121 -0
- super_browser/stealth/diagnostics.py +204 -0
- super_browser/stealth/ejecta/__init__.py +20 -0
- super_browser/stealth/ejecta/audio.py +182 -0
- super_browser/stealth/ejecta/browser_apis.py +225 -0
- super_browser/stealth/ejecta/canvas.py +210 -0
- super_browser/stealth/ejecta/config.py +48 -0
- super_browser/stealth/ejecta/registry.py +55 -0
- super_browser/stealth/ejecta/timing.py +159 -0
- super_browser/stealth/ejecta/types.py +30 -0
- super_browser/stealth/ejecta/webrtc.py +120 -0
- super_browser/stealth/fingerprint_scanner.py +187 -0
- super_browser/stealth/fingerprint_score.py +122 -0
- super_browser/stealth/headers.py +115 -0
- super_browser/stealth/human.py +419 -0
- super_browser/stealth/human_config.py +158 -0
- super_browser/stealth/ip_reputation.py +330 -0
- super_browser/stealth/manager.py +463 -0
- super_browser/stealth/profiles/__init__.py +145 -0
- super_browser/stealth/profiles/data/linux-chrome-stable.json +98 -0
- super_browser/stealth/profiles/data/macos-chrome-stable.json +130 -0
- super_browser/stealth/profiles/data/macos-m4-chrome-stable.json +132 -0
- super_browser/stealth/profiles/data/windows-chrome-stable.json +103 -0
- super_browser/stealth/profiles/host_detect.py +32 -0
- super_browser/stealth/profiles/schema.py +165 -0
- super_browser/stealth/proxy.py +86 -0
- super_browser/stealth/proxy_pool.py +490 -0
- super_browser/stealth/report.py +111 -0
- super_browser/stealth/scoring.py +54 -0
- super_browser/stealth/tls_baselines.json +36 -0
- super_browser/stealth/tls_fingerprint.py +494 -0
- super_browser/stealth/types.py +179 -0
- super_browser/stealth/user_agent_pool.py +148 -0
- super_browser/stealth/validation/__init__.py +13 -0
- super_browser/stealth/validation/checks.py +334 -0
- super_browser/stealth/validation/harness.py +161 -0
- super_browser/stealth/validation/report.py +31 -0
- super_browser/stealth/validation/suite.py +49 -0
- super_browser/testing.py +422 -0
- super_browser/tracing/__init__.py +29 -0
- super_browser/tracing/cost_analytics.py +49 -0
- super_browser/tracing/flow_logger.py +283 -0
- super_browser/tracing/middleware.py +42 -0
- super_browser/tracing/session_db.py +243 -0
- super_browser/tracing/sinks.py +166 -0
- super_browser/tracing/types.py +175 -0
- super_browser/verification/__init__.py +39 -0
- super_browser/verification/ax_diff.py +65 -0
- super_browser/verification/hasher.py +154 -0
- super_browser/verification/types.py +153 -0
- super_browser/verification/verifier.py +331 -0
- super_browser/vision/__init__.py +49 -0
- super_browser/vision/cache.py +178 -0
- super_browser/vision/controller.py +373 -0
- super_browser/vision/coords.py +57 -0
- super_browser/vision/factory.py +116 -0
- super_browser/vision/ocr.py +140 -0
- super_browser/vision/providers.py +348 -0
- super_browser/vision/types.py +110 -0
- superbrowser_sdk-2.0.0.dist-info/METADATA +562 -0
- superbrowser_sdk-2.0.0.dist-info/RECORD +217 -0
- superbrowser_sdk-2.0.0.dist-info/WHEEL +4 -0
- superbrowser_sdk-2.0.0.dist-info/entry_points.txt +2 -0
- superbrowser_sdk-2.0.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
"""SuperBrowser facade — primary entry point for all browser automation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncIterator, Callable
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
10
|
+
|
|
11
|
+
from super_browser.agent.delegator import SubagentDelegator
|
|
12
|
+
from super_browser.agent.loop import AgentLoop
|
|
13
|
+
from super_browser.agent.registry import ToolRegistry
|
|
14
|
+
from super_browser.agent.types import DelegationResult, StepEvent, StreamEvent
|
|
15
|
+
from super_browser.browser.config import SessionConfig
|
|
16
|
+
from super_browser.browser.engine import _detect_backend
|
|
17
|
+
from super_browser.browser.session import BrowserSession
|
|
18
|
+
from super_browser.browser.tabs import TabManager, TabSnapshot
|
|
19
|
+
from super_browser.config import Config
|
|
20
|
+
from super_browser.interaction.controller import MultimodalController
|
|
21
|
+
from super_browser.results import (
|
|
22
|
+
ActionError,
|
|
23
|
+
ActionMethod,
|
|
24
|
+
ActionResult,
|
|
25
|
+
CompletionReason,
|
|
26
|
+
DelegatedResult,
|
|
27
|
+
DownloadResult,
|
|
28
|
+
ErrorCategory,
|
|
29
|
+
ExtractResult,
|
|
30
|
+
NavigateResult,
|
|
31
|
+
NetworkInterceptResult,
|
|
32
|
+
ShadowQueryResult,
|
|
33
|
+
UploadResult,
|
|
34
|
+
action_result,
|
|
35
|
+
timed_action_result,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from super_browser.agent.llm.protocol import LLMClient
|
|
40
|
+
from super_browser.memory.store import MemoryStore
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SuperBrowser:
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
config: Optional[Config] = None,
|
|
50
|
+
*,
|
|
51
|
+
tool_registry: Optional[ToolRegistry] = None,
|
|
52
|
+
llm_client: Optional[LLMClient] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if config is None:
|
|
55
|
+
self._config = Config()
|
|
56
|
+
elif isinstance(config, Config):
|
|
57
|
+
self._config = config
|
|
58
|
+
else:
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"config must be Config or None, got {type(config).__name__}"
|
|
61
|
+
)
|
|
62
|
+
self._registry = tool_registry or ToolRegistry()
|
|
63
|
+
self._llm_client = llm_client
|
|
64
|
+
self._session: Optional[BrowserSession] = None
|
|
65
|
+
self._engine: Any = None
|
|
66
|
+
self._controller: Optional[MultimodalController] = None
|
|
67
|
+
self._page: Any = None
|
|
68
|
+
self._abort_signal = asyncio.Event()
|
|
69
|
+
self._running = False
|
|
70
|
+
self._coordinator: Any = None
|
|
71
|
+
self._budget_client: Any = None
|
|
72
|
+
self._flow_logger: Any = None
|
|
73
|
+
self._security_manager: Any = None
|
|
74
|
+
self._vision_controller: Any = None
|
|
75
|
+
self._stealth_manager: Any = None
|
|
76
|
+
self._skill_registry: Any = None
|
|
77
|
+
self._tab_manager: Optional[TabManager] = None
|
|
78
|
+
self._frame_stack: list[Any] = [] # stack of Frame objects
|
|
79
|
+
self._network_interceptors: list[Any] = []
|
|
80
|
+
self._recorder: Any = None # Optional[SessionRecorder]
|
|
81
|
+
self._event_bus: Any = None # Optional[EventBus]
|
|
82
|
+
self._memory_store: Optional[MemoryStore] = None # Set via enable_memory()
|
|
83
|
+
|
|
84
|
+
# -- Lifecycle --
|
|
85
|
+
|
|
86
|
+
async def start(self) -> None:
|
|
87
|
+
cfg = self._config
|
|
88
|
+
# -- Determine backend & session config from composition root --
|
|
89
|
+
backend_name = _detect_backend(cfg)
|
|
90
|
+
session_config = cfg.browser if isinstance(cfg, Config) else SessionConfig(headless=True)
|
|
91
|
+
if backend_name == "patchright":
|
|
92
|
+
from super_browser.browser.backends.patchright_backend import PatchrightEngine
|
|
93
|
+
self._engine = PatchrightEngine(session_config)
|
|
94
|
+
await self._engine.start()
|
|
95
|
+
self._session = self._engine.session
|
|
96
|
+
self._page = await self._engine.new_page()
|
|
97
|
+
else:
|
|
98
|
+
self._session = BrowserSession(session_config)
|
|
99
|
+
await self._session.start()
|
|
100
|
+
self._page = await self._session.new_page()
|
|
101
|
+
self._controller = MultimodalController(self._page, self._page.engine_page.cdp)
|
|
102
|
+
self._running = True
|
|
103
|
+
self._register_builtin_tools()
|
|
104
|
+
self._configure_verification()
|
|
105
|
+
self._configure_vision()
|
|
106
|
+
self._configure_stealth()
|
|
107
|
+
self._configure_skills()
|
|
108
|
+
# -- Recovery --
|
|
109
|
+
if cfg.agent.enable_recovery:
|
|
110
|
+
from super_browser.recovery import RecoveryCoordinator
|
|
111
|
+
|
|
112
|
+
self._coordinator = RecoveryCoordinator(
|
|
113
|
+
session=self._session, controller=self._controller,
|
|
114
|
+
)
|
|
115
|
+
await self._coordinator.start()
|
|
116
|
+
# -- Budget --
|
|
117
|
+
if cfg.agent.enable_budget:
|
|
118
|
+
from super_browser.budget import (
|
|
119
|
+
BudgetAwareLLMClient,
|
|
120
|
+
CircuitBreaker,
|
|
121
|
+
ContextCompressor,
|
|
122
|
+
CredentialPool,
|
|
123
|
+
ModelCascade,
|
|
124
|
+
TokenBudgetGovernor,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
governor = TokenBudgetGovernor()
|
|
128
|
+
cascade = ModelCascade(governor=governor)
|
|
129
|
+
pool = CredentialPool()
|
|
130
|
+
cb = CircuitBreaker()
|
|
131
|
+
comp = ContextCompressor()
|
|
132
|
+
self._budget_client = BudgetAwareLLMClient(governor, cascade, pool, cb, comp)
|
|
133
|
+
# -- Tracing --
|
|
134
|
+
if cfg.tracing.enabled or cfg.agent.trace_enabled:
|
|
135
|
+
from super_browser.tracing import FlowLogger
|
|
136
|
+
from super_browser.tracing.sinks import ConsoleSink
|
|
137
|
+
sinks = [ConsoleSink()]
|
|
138
|
+
trace_dir = cfg.tracing.output_dir or cfg.agent.trace_output_dir
|
|
139
|
+
if trace_dir:
|
|
140
|
+
from pathlib import Path
|
|
141
|
+
|
|
142
|
+
from super_browser.tracing.sinks import FileSink
|
|
143
|
+
path = Path(trace_dir) / "trace.jsonl"
|
|
144
|
+
sinks.append(FileSink(path))
|
|
145
|
+
self._flow_logger = FlowLogger(sinks=sinks)
|
|
146
|
+
await self._flow_logger.start()
|
|
147
|
+
# -- Security --
|
|
148
|
+
if cfg.agent.enable_security:
|
|
149
|
+
from super_browser.security import SecurityManager
|
|
150
|
+
sec_config = cfg.security
|
|
151
|
+
self._security_manager = SecurityManager(sec_config)
|
|
152
|
+
logger.info("SuperBrowser started")
|
|
153
|
+
|
|
154
|
+
async def stop(self) -> None:
|
|
155
|
+
self._running = False
|
|
156
|
+
if self._flow_logger:
|
|
157
|
+
await self._flow_logger.stop()
|
|
158
|
+
self._flow_logger = None
|
|
159
|
+
if self._coordinator:
|
|
160
|
+
await self._coordinator.stop()
|
|
161
|
+
self._coordinator = None
|
|
162
|
+
if self._session:
|
|
163
|
+
await self._session.stop()
|
|
164
|
+
self._session = None
|
|
165
|
+
self._controller = None
|
|
166
|
+
self._page = None
|
|
167
|
+
logger.info("SuperBrowser stopped")
|
|
168
|
+
|
|
169
|
+
async def __aenter__(self) -> SuperBrowser:
|
|
170
|
+
await self.start()
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
174
|
+
await self.stop()
|
|
175
|
+
|
|
176
|
+
# -- Facade methods --
|
|
177
|
+
|
|
178
|
+
async def navigate(self, url: str, *, wait_until: str = "domcontentloaded") -> ActionResult:
|
|
179
|
+
"""Navigate to a URL. Enforces facade security before side effects."""
|
|
180
|
+
if not self._page:
|
|
181
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Not started"))
|
|
182
|
+
params = {"url": url}
|
|
183
|
+
sec = await self._check_facade_security("navigate", params, url=url)
|
|
184
|
+
if sec is not None:
|
|
185
|
+
return sec
|
|
186
|
+
url = params["url"] # consume potentially redacted URL
|
|
187
|
+
return await self._navigate_impl(url, wait_until=wait_until)
|
|
188
|
+
|
|
189
|
+
async def _navigate_impl(self, url: str, *, wait_until: str = "domcontentloaded") -> ActionResult:
|
|
190
|
+
"""Navigation logic without facade security check.
|
|
191
|
+
|
|
192
|
+
Security is enforced by the caller — either :meth:`navigate` (direct
|
|
193
|
+
SDK path) or :meth:`AgentLoop._dispatch_action` (agent loop path).
|
|
194
|
+
Registered as the ``navigate`` tool so the agent loop does not
|
|
195
|
+
double-check security.
|
|
196
|
+
"""
|
|
197
|
+
start = time.monotonic()
|
|
198
|
+
if not self._page:
|
|
199
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Not started"))
|
|
200
|
+
await self._page.goto(url, wait_until=wait_until)
|
|
201
|
+
final_url = self._page.url
|
|
202
|
+
title = await self._page.title()
|
|
203
|
+
skills_data = {}
|
|
204
|
+
if self._skill_registry:
|
|
205
|
+
try:
|
|
206
|
+
discovered = await self._skill_registry.auto_discover(url)
|
|
207
|
+
skills_data = {"skills": [{"id": s.skill_id, "name": s.name} for s in discovered]} # noqa: F841
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
return timed_action_result(
|
|
211
|
+
ok=True,
|
|
212
|
+
start_ns=start,
|
|
213
|
+
data=NavigateResult(url=url, final_url=final_url, title=title),
|
|
214
|
+
method=ActionMethod.SELECTOR,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def click(self, target: str, *, description: Optional[str] = None) -> ActionResult:
|
|
218
|
+
if not self._controller:
|
|
219
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started. Call await sb.start() first."))
|
|
220
|
+
sec = await self._check_facade_security("click", {"target": target})
|
|
221
|
+
if sec is not None:
|
|
222
|
+
return sec
|
|
223
|
+
return await self._controller.click(target, description=description)
|
|
224
|
+
|
|
225
|
+
async def fill(self, target: str, value: str, *, clear_first: bool = True, description: Optional[str] = None) -> ActionResult:
|
|
226
|
+
if not self._controller:
|
|
227
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started. Call await sb.start() first."))
|
|
228
|
+
params = {"target": target, "value": value}
|
|
229
|
+
sec = await self._check_facade_security("fill", params)
|
|
230
|
+
if sec is not None:
|
|
231
|
+
return sec
|
|
232
|
+
return await self._controller.fill(params["target"], params["value"], clear_first=clear_first, description=description)
|
|
233
|
+
|
|
234
|
+
async def act(self, instruction: str, *, max_steps: int = 50) -> ActionResult:
|
|
235
|
+
if not self._controller:
|
|
236
|
+
return action_result(ok=False)
|
|
237
|
+
|
|
238
|
+
if self._llm_client is None:
|
|
239
|
+
raise ConfigurationError(
|
|
240
|
+
"No LLM client configured. Pass llm_client= to SuperBrowser()."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if not self._controller:
|
|
244
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started. Call await sb.start() first."))
|
|
245
|
+
|
|
246
|
+
loop = AgentLoop(
|
|
247
|
+
controller=self._controller,
|
|
248
|
+
registry=self._registry,
|
|
249
|
+
llm_client=self._llm_client,
|
|
250
|
+
max_steps=max_steps,
|
|
251
|
+
recovery_coordinator=self._coordinator,
|
|
252
|
+
budget_client=self._budget_client,
|
|
253
|
+
flow_logger=self._flow_logger,
|
|
254
|
+
security_manager=self._security_manager,
|
|
255
|
+
stealth_manager=getattr(self, '_stealth_manager', None),
|
|
256
|
+
debug_config=getattr(self._config, 'debug_config', None),
|
|
257
|
+
retry_budget=getattr(self._config, 'retry_budget', None),
|
|
258
|
+
)
|
|
259
|
+
# Wire memory into the loop if enabled
|
|
260
|
+
if self._memory_store is not None and self._page:
|
|
261
|
+
try:
|
|
262
|
+
current_url = self._page.url
|
|
263
|
+
except Exception:
|
|
264
|
+
current_url = ""
|
|
265
|
+
loop.set_memory_store(self._memory_store, current_url=current_url)
|
|
266
|
+
result = await loop.run(instruction)
|
|
267
|
+
return action_result(
|
|
268
|
+
ok=result.completion_reason == "success",
|
|
269
|
+
data=DelegatedResult(
|
|
270
|
+
instruction=instruction,
|
|
271
|
+
completion_reason=CompletionReason.SUCCESS if result.completion_reason == "success" else CompletionReason.ERROR,
|
|
272
|
+
summary=f"Completed in {result.total_steps} steps",
|
|
273
|
+
steps_executed=result.total_steps,
|
|
274
|
+
budget_remaining=self._budget_client.budget_remaining if self._budget_client else 0.0,
|
|
275
|
+
execution_history=[{"step": s.step_number, "action": s.action_name} for s in result.steps],
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def act_stream(
|
|
280
|
+
self,
|
|
281
|
+
instruction: str,
|
|
282
|
+
*,
|
|
283
|
+
max_steps: int = 50,
|
|
284
|
+
) -> AsyncIterator[StreamEvent]:
|
|
285
|
+
"""Run the agent loop and yield streaming lifecycle events.
|
|
286
|
+
|
|
287
|
+
Unlike :meth:`act` which blocks until completion, ``act_stream``
|
|
288
|
+
yields a :class:`StreamEvent` for each step lifecycle event, allowing
|
|
289
|
+
callers to observe progress in real time.
|
|
290
|
+
|
|
291
|
+
The final event is ``StepEvent.DONE`` with ``completion_reason``,
|
|
292
|
+
``total_steps``, and ``total_duration_ms``.
|
|
293
|
+
|
|
294
|
+
Usage::
|
|
295
|
+
|
|
296
|
+
async for event in sb.act_stream("Fill the form"):
|
|
297
|
+
if event.type == "step_complete":
|
|
298
|
+
print(f"Step done: {event.data}")
|
|
299
|
+
if event.type == "done":
|
|
300
|
+
print(f"Finished: {event.data['completion_reason']}")
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
AsyncIterator[StreamEvent] — yields events until the loop completes.
|
|
304
|
+
"""
|
|
305
|
+
if not self._controller:
|
|
306
|
+
yield StreamEvent(type=StepEvent.ABORT, data={"reason": "not_started"})
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
if self._llm_client is None:
|
|
310
|
+
raise ConfigurationError(
|
|
311
|
+
"No LLM client configured. Pass llm_client= to SuperBrowser()."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
loop = AgentLoop(
|
|
315
|
+
controller=self._controller,
|
|
316
|
+
registry=self._registry,
|
|
317
|
+
llm_client=self._llm_client,
|
|
318
|
+
max_steps=max_steps,
|
|
319
|
+
recovery_coordinator=self._coordinator,
|
|
320
|
+
budget_client=self._budget_client,
|
|
321
|
+
flow_logger=self._flow_logger,
|
|
322
|
+
security_manager=self._security_manager,
|
|
323
|
+
stealth_manager=getattr(self, '_stealth_manager', None),
|
|
324
|
+
debug_config=getattr(self._config, 'debug_config', None),
|
|
325
|
+
retry_budget=getattr(self._config, 'retry_budget', None),
|
|
326
|
+
)
|
|
327
|
+
if self._memory_store is not None and self._page:
|
|
328
|
+
try:
|
|
329
|
+
current_url = self._page.url
|
|
330
|
+
except Exception:
|
|
331
|
+
current_url = ""
|
|
332
|
+
loop.set_memory_store(self._memory_store, current_url=current_url)
|
|
333
|
+
|
|
334
|
+
async for event in loop.run_stream(instruction):
|
|
335
|
+
yield event
|
|
336
|
+
|
|
337
|
+
async def extract(self, query: str, *, selector: Optional[str] = None, schema: Optional[dict] = None) -> ActionResult:
|
|
338
|
+
"""Extract content from the current page.
|
|
339
|
+
|
|
340
|
+
:param query: Description of what to extract.
|
|
341
|
+
:param selector: Optional CSS selector for targeted extraction.
|
|
342
|
+
:param schema: Optional JSON schema to validate/structure the output.
|
|
343
|
+
:returns: ActionResult with data=ExtractResult.
|
|
344
|
+
"""
|
|
345
|
+
import json as _json
|
|
346
|
+
start = time.monotonic()
|
|
347
|
+
if not self._controller:
|
|
348
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started. Call await sb.start() first."))
|
|
349
|
+
|
|
350
|
+
if selector:
|
|
351
|
+
# Use CDP Runtime.evaluate with expression argument to avoid injection
|
|
352
|
+
# We escape the selector for safe embedding in a JS string literal
|
|
353
|
+
safe_selector = selector.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n').replace('\r', '\\r')
|
|
354
|
+
result = await self._controller._cdp.evaluate(
|
|
355
|
+
f"(function() {{ var el = document.querySelector('{safe_selector}');"
|
|
356
|
+
f" return el ? el.textContent : null; }})()"
|
|
357
|
+
)
|
|
358
|
+
if result.ok and 'exceptionDetails' not in result.data:
|
|
359
|
+
extracted = result.data.get("result", {}).get("value")
|
|
360
|
+
else:
|
|
361
|
+
extracted = None
|
|
362
|
+
else:
|
|
363
|
+
snap = await self._controller.capture_ax_snapshot()
|
|
364
|
+
extracted = snap.to_compact_str()
|
|
365
|
+
|
|
366
|
+
# Schema validation
|
|
367
|
+
if schema and extracted is not None:
|
|
368
|
+
try:
|
|
369
|
+
import jsonschema
|
|
370
|
+
# If extracted is a string, try to parse as JSON
|
|
371
|
+
if isinstance(extracted, str):
|
|
372
|
+
try:
|
|
373
|
+
parsed = _json.loads(extracted)
|
|
374
|
+
except (_json.JSONDecodeError, ValueError):
|
|
375
|
+
parsed = {"text": extracted}
|
|
376
|
+
else:
|
|
377
|
+
parsed = extracted
|
|
378
|
+
jsonschema.validate(parsed, schema)
|
|
379
|
+
except ImportError:
|
|
380
|
+
# jsonschema not installed — skip validation
|
|
381
|
+
pass
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return timed_action_result(
|
|
384
|
+
ok=False, start_ns=start,
|
|
385
|
+
error=ActionError(ErrorCategory.SELECTOR_NOT_FOUND, f"Schema validation failed: {e}"),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return timed_action_result(
|
|
389
|
+
ok=True,
|
|
390
|
+
start_ns=start,
|
|
391
|
+
data=ExtractResult(selector=selector or query, extracted=extracted, element_count=0),
|
|
392
|
+
method=ActionMethod.SELECTOR,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
async def observe(self) -> ActionResult:
|
|
396
|
+
start = time.monotonic()
|
|
397
|
+
if not self._controller:
|
|
398
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started. Call await sb.start() first."))
|
|
399
|
+
|
|
400
|
+
url = self._page.url
|
|
401
|
+
title = await self._page.title()
|
|
402
|
+
snap = await self._controller.capture_ax_snapshot()
|
|
403
|
+
interactive_count = sum(1 for n in snap.nodes.values() if n.is_interactive)
|
|
404
|
+
|
|
405
|
+
return timed_action_result(
|
|
406
|
+
ok=True,
|
|
407
|
+
start_ns=start,
|
|
408
|
+
data={"url": url, "title": title, "interactive_elements": interactive_count, "total_elements": len(snap.nodes)},
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# -- Tab helper (CHK-07) --
|
|
412
|
+
|
|
413
|
+
async def _attach_page(self, page_obj: Any) -> None:
|
|
414
|
+
"""Wire a raw Playwright Page into the facade's _page and _controller.
|
|
415
|
+
|
|
416
|
+
Shared by :meth:`open_tab` and :meth:`switch_tab`.
|
|
417
|
+
"""
|
|
418
|
+
ctx = self._engine.context
|
|
419
|
+
if ctx is None:
|
|
420
|
+
raise RuntimeError("Browser context not available — engine not started?")
|
|
421
|
+
from super_browser.browser.page import PageHandle
|
|
422
|
+
cdp_session = await ctx.new_cdp_session(page_obj)
|
|
423
|
+
from super_browser.browser.cdp import CDPBridge
|
|
424
|
+
from super_browser.browser.config import SessionConfig as _SC
|
|
425
|
+
cdp = CDPBridge(cdp_session, _SC())
|
|
426
|
+
self._page = PageHandle(page_obj, cdp)
|
|
427
|
+
self._controller = MultimodalController(self._page, self._page.engine_page.cdp)
|
|
428
|
+
|
|
429
|
+
# -- Multi-Tab --
|
|
430
|
+
|
|
431
|
+
async def open_tab(self, url: Optional[str] = None) -> ActionResult:
|
|
432
|
+
"""Open a new browser tab, optionally navigating to a URL.
|
|
433
|
+
|
|
434
|
+
:param url: Optional URL to navigate to.
|
|
435
|
+
:returns: ActionResult with data=TabHandle.
|
|
436
|
+
"""
|
|
437
|
+
start = time.monotonic()
|
|
438
|
+
if not self._session:
|
|
439
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
440
|
+
ctx = self._engine.context
|
|
441
|
+
if ctx is None:
|
|
442
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser context not available"))
|
|
443
|
+
if self._tab_manager is None:
|
|
444
|
+
self._tab_manager = TabManager(ctx)
|
|
445
|
+
params = {"url": url or ""}
|
|
446
|
+
sec = await self._check_facade_security("open_tab", params, url=url or "")
|
|
447
|
+
if sec is not None:
|
|
448
|
+
return sec
|
|
449
|
+
url = params["url"] or None # consume potentially redacted URL
|
|
450
|
+
try:
|
|
451
|
+
tab = await self._tab_manager.open_tab(url)
|
|
452
|
+
# Update page reference and controller to new tab
|
|
453
|
+
page_obj = self._tab_manager.get_page(tab.tab_id)
|
|
454
|
+
await self._attach_page(page_obj)
|
|
455
|
+
return timed_action_result(ok=True, start_ns=start, data=tab)
|
|
456
|
+
except Exception as e:
|
|
457
|
+
return timed_action_result(ok=False, start_ns=start, error=ActionError(ErrorCategory.NAVIGATION, str(e)))
|
|
458
|
+
|
|
459
|
+
async def switch_tab(self, tab_id: int) -> ActionResult:
|
|
460
|
+
"""Switch to a different tab by ID.
|
|
461
|
+
|
|
462
|
+
:param tab_id: The tab ID from open_tab().
|
|
463
|
+
:returns: ActionResult with data=TabHandle.
|
|
464
|
+
"""
|
|
465
|
+
start = time.monotonic()
|
|
466
|
+
if not self._tab_manager:
|
|
467
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "No tabs open"))
|
|
468
|
+
params = {"tab_id": tab_id}
|
|
469
|
+
sec = await self._check_facade_security("switch_tab", params, security_level="sensitive")
|
|
470
|
+
if sec is not None:
|
|
471
|
+
return sec
|
|
472
|
+
tab_id = params["tab_id"]
|
|
473
|
+
try:
|
|
474
|
+
tab = await self._tab_manager.switch_tab(tab_id)
|
|
475
|
+
page_obj = self._tab_manager.get_page(tab_id)
|
|
476
|
+
await self._attach_page(page_obj)
|
|
477
|
+
return timed_action_result(ok=True, start_ns=start, data=tab)
|
|
478
|
+
except KeyError as e:
|
|
479
|
+
return timed_action_result(ok=False, start_ns=start, error=ActionError(ErrorCategory.SELECTOR_NOT_FOUND, str(e)))
|
|
480
|
+
|
|
481
|
+
async def close_tab(self, tab_id: int) -> ActionResult:
|
|
482
|
+
"""Close a tab by ID.
|
|
483
|
+
|
|
484
|
+
:param tab_id: The tab ID to close.
|
|
485
|
+
"""
|
|
486
|
+
start = time.monotonic()
|
|
487
|
+
if not self._tab_manager:
|
|
488
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "No tabs open"))
|
|
489
|
+
params = {"tab_id": tab_id}
|
|
490
|
+
sec = await self._check_facade_security("close_tab", params, security_level="sensitive")
|
|
491
|
+
if sec is not None:
|
|
492
|
+
return sec
|
|
493
|
+
tab_id = params["tab_id"]
|
|
494
|
+
try:
|
|
495
|
+
await self._tab_manager.close_tab(tab_id)
|
|
496
|
+
return timed_action_result(ok=True, start_ns=start, data={"closed_tab": tab_id})
|
|
497
|
+
except KeyError as e:
|
|
498
|
+
return timed_action_result(ok=False, start_ns=start, error=ActionError(ErrorCategory.SELECTOR_NOT_FOUND, str(e)))
|
|
499
|
+
|
|
500
|
+
async def list_tabs(self) -> ActionResult:
|
|
501
|
+
"""List all open tabs."""
|
|
502
|
+
start = time.monotonic()
|
|
503
|
+
if not self._tab_manager:
|
|
504
|
+
return timed_action_result(ok=True, start_ns=start, data=TabSnapshot())
|
|
505
|
+
snap = await self._tab_manager.list_tabs()
|
|
506
|
+
return timed_action_result(ok=True, start_ns=start, data=snap)
|
|
507
|
+
|
|
508
|
+
# -- File I/O --
|
|
509
|
+
|
|
510
|
+
async def upload_file(self, selector: str, file_path: str) -> ActionResult:
|
|
511
|
+
"""Upload a file to an <input type='file'> element.
|
|
512
|
+
|
|
513
|
+
:param selector: CSS selector for the file input.
|
|
514
|
+
:param file_path: Absolute or relative path to the file.
|
|
515
|
+
:returns: ActionResult with data=UploadResult.
|
|
516
|
+
"""
|
|
517
|
+
start = time.monotonic()
|
|
518
|
+
if not self._page:
|
|
519
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
520
|
+
params = {"selector": selector, "file_path": file_path}
|
|
521
|
+
sec = await self._check_facade_security("upload_file", params, security_level="dangerous")
|
|
522
|
+
if sec is not None:
|
|
523
|
+
return sec
|
|
524
|
+
selector = params["selector"]
|
|
525
|
+
file_path = params["file_path"]
|
|
526
|
+
try:
|
|
527
|
+
await self._page.engine_page.set_input_files(selector, file_path)
|
|
528
|
+
import os
|
|
529
|
+
fname = os.path.basename(file_path)
|
|
530
|
+
return timed_action_result(
|
|
531
|
+
ok=True, start_ns=start,
|
|
532
|
+
data=UploadResult(selector=selector, file_path=file_path, file_name=fname),
|
|
533
|
+
)
|
|
534
|
+
except Exception as e:
|
|
535
|
+
return timed_action_result(
|
|
536
|
+
ok=False, start_ns=start,
|
|
537
|
+
error=ActionError(ErrorCategory.SELECTOR_NOT_FOUND, f"Upload failed: {e}"),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
async def download(self, url_or_selector: str, *, save_path: Optional[str] = None) -> ActionResult:
|
|
541
|
+
"""Download a file by clicking a link or navigating to a URL.
|
|
542
|
+
|
|
543
|
+
:param url_or_selector: URL to download from, or selector for a download link.
|
|
544
|
+
:param save_path: Optional directory to save the file.
|
|
545
|
+
:returns: ActionResult with data=DownloadResult.
|
|
546
|
+
"""
|
|
547
|
+
start = time.monotonic()
|
|
548
|
+
if not self._page:
|
|
549
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
550
|
+
params = {"url_or_selector": url_or_selector, "save_path": save_path or ""}
|
|
551
|
+
# For URL-mode downloads, check against the download target URL.
|
|
552
|
+
# For selector-mode, _check_facade_security derives current page URL.
|
|
553
|
+
security_url = url_or_selector if url_or_selector.startswith(("http://", "https://")) else ""
|
|
554
|
+
sec = await self._check_facade_security("download", params, url=security_url)
|
|
555
|
+
if sec is not None:
|
|
556
|
+
return sec
|
|
557
|
+
url_or_selector = params["url_or_selector"]
|
|
558
|
+
save_path = params["save_path"] or None
|
|
559
|
+
try:
|
|
560
|
+
# Start listening for download
|
|
561
|
+
async with self._page.engine_page.expect_download() as download_info:
|
|
562
|
+
if url_or_selector.startswith("http"):
|
|
563
|
+
await self._page.engine_page.evaluate(
|
|
564
|
+
"(url) => { const a = document.createElement('a'); a.href = url; a.download = ''; a.click(); }",
|
|
565
|
+
url_or_selector,
|
|
566
|
+
)
|
|
567
|
+
else:
|
|
568
|
+
await self._page.engine_page.click(url_or_selector)
|
|
569
|
+
download = await download_info.value
|
|
570
|
+
suggested = download.suggested_filename
|
|
571
|
+
if save_path:
|
|
572
|
+
import os
|
|
573
|
+
dest = os.path.join(save_path, suggested)
|
|
574
|
+
await download.save_as(dest)
|
|
575
|
+
else:
|
|
576
|
+
dest = await download.path()
|
|
577
|
+
file_size = 0
|
|
578
|
+
import os
|
|
579
|
+
if dest and os.path.exists(dest):
|
|
580
|
+
file_size = os.path.getsize(dest)
|
|
581
|
+
return timed_action_result(
|
|
582
|
+
ok=True, start_ns=start,
|
|
583
|
+
data=DownloadResult(
|
|
584
|
+
url=url_or_selector,
|
|
585
|
+
file_path=str(dest) if dest else "",
|
|
586
|
+
file_size_bytes=file_size,
|
|
587
|
+
suggested_filename=suggested,
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
except Exception as e:
|
|
591
|
+
return timed_action_result(
|
|
592
|
+
ok=False, start_ns=start,
|
|
593
|
+
error=ActionError(ErrorCategory.NAVIGATION, f"Download failed: {e}"),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# -- iframe --
|
|
597
|
+
|
|
598
|
+
async def enter_frame(self, selector: str) -> ActionResult:
|
|
599
|
+
"""Enter an iframe, scoping subsequent interactions to it.
|
|
600
|
+
|
|
601
|
+
:param selector: CSS selector for the iframe element.
|
|
602
|
+
:returns: ActionResult indicating success.
|
|
603
|
+
"""
|
|
604
|
+
start = time.monotonic()
|
|
605
|
+
if not self._page:
|
|
606
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
607
|
+
params = {"selector": selector}
|
|
608
|
+
sec = await self._check_facade_security("enter_frame", params, security_level="sensitive")
|
|
609
|
+
if sec is not None:
|
|
610
|
+
return sec
|
|
611
|
+
selector = params["selector"]
|
|
612
|
+
try:
|
|
613
|
+
frame = self._page.engine_page.frame_locator(selector)
|
|
614
|
+
self._frame_stack.append(frame)
|
|
615
|
+
return timed_action_result(ok=True, start_ns=start, data={"frame": selector, "depth": len(self._frame_stack)})
|
|
616
|
+
except Exception as e:
|
|
617
|
+
return timed_action_result(ok=False, start_ns=start, error=ActionError(ErrorCategory.SELECTOR_NOT_FOUND, f"Frame not found: {e}"))
|
|
618
|
+
|
|
619
|
+
async def exit_frame(self) -> ActionResult:
|
|
620
|
+
"""Exit the current iframe, returning to the parent frame."""
|
|
621
|
+
start = time.monotonic()
|
|
622
|
+
sec = await self._check_facade_security("exit_frame", {}, security_level="sensitive")
|
|
623
|
+
if sec is not None:
|
|
624
|
+
return sec
|
|
625
|
+
if self._frame_stack:
|
|
626
|
+
self._frame_stack.pop()
|
|
627
|
+
return timed_action_result(ok=True, start_ns=start, data={"depth": len(self._frame_stack)})
|
|
628
|
+
return timed_action_result(ok=True, start_ns=start, data={"depth": 0})
|
|
629
|
+
|
|
630
|
+
def _current_frame(self) -> Any:
|
|
631
|
+
"""Get the current frame (top of stack) or the raw page."""
|
|
632
|
+
if self._frame_stack:
|
|
633
|
+
return self._frame_stack[-1]
|
|
634
|
+
return self._page.engine_page.backend_page if self._page else None
|
|
635
|
+
# NOTE: Returns underlying Playwright Page for backward compat.
|
|
636
|
+
|
|
637
|
+
# -- Shadow DOM --
|
|
638
|
+
|
|
639
|
+
async def query_shadow(self, host_selector: str, inner_selector: str) -> ActionResult:
|
|
640
|
+
"""Query an element inside a Shadow DOM.
|
|
641
|
+
|
|
642
|
+
:param host_selector: CSS selector for the custom element (shadow host).
|
|
643
|
+
:param inner_selector: CSS selector inside the shadow root.
|
|
644
|
+
:returns: ActionResult with data=ShadowQueryResult.
|
|
645
|
+
"""
|
|
646
|
+
start = time.monotonic()
|
|
647
|
+
if not self._controller:
|
|
648
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
649
|
+
try:
|
|
650
|
+
import json as _json
|
|
651
|
+
host_json = _json.dumps(host_selector)
|
|
652
|
+
inner_json = _json.dumps(inner_selector)
|
|
653
|
+
expr = (
|
|
654
|
+
'(function() {'
|
|
655
|
+
' var host = document.querySelector(JSON.parse(' + host_json + '));'
|
|
656
|
+
' if (!host || !host.shadowRoot) return JSON.stringify({found: false});'
|
|
657
|
+
' var el = host.shadowRoot.querySelector(JSON.parse(' + inner_json + '));'
|
|
658
|
+
' if (!el) return JSON.stringify({found: false});'
|
|
659
|
+
' var rect = el.getBoundingClientRect();'
|
|
660
|
+
' return JSON.stringify({'
|
|
661
|
+
' found: true,'
|
|
662
|
+
' text: el.textContent || "",'
|
|
663
|
+
' bounds: {x: rect.x, y: rect.y, w: rect.width, h: rect.height}'
|
|
664
|
+
' });'
|
|
665
|
+
'})()'
|
|
666
|
+
)
|
|
667
|
+
result = await self._controller._cdp.evaluate(expr)
|
|
668
|
+
if result.ok and result.data:
|
|
669
|
+
val = result.data.get("result", {}).get("value")
|
|
670
|
+
if val:
|
|
671
|
+
import json
|
|
672
|
+
parsed = json.loads(val)
|
|
673
|
+
return timed_action_result(
|
|
674
|
+
ok=True, start_ns=start,
|
|
675
|
+
data=ShadowQueryResult(
|
|
676
|
+
host_selector=host_selector,
|
|
677
|
+
inner_selector=inner_selector,
|
|
678
|
+
text=parsed.get("text"),
|
|
679
|
+
bounds=parsed.get("bounds"),
|
|
680
|
+
found=parsed.get("found", False),
|
|
681
|
+
),
|
|
682
|
+
)
|
|
683
|
+
return timed_action_result(
|
|
684
|
+
ok=True, start_ns=start,
|
|
685
|
+
data=ShadowQueryResult(host_selector=host_selector, inner_selector=inner_selector, found=False),
|
|
686
|
+
)
|
|
687
|
+
except Exception as e:
|
|
688
|
+
return timed_action_result(
|
|
689
|
+
ok=False, start_ns=start,
|
|
690
|
+
error=ActionError(ErrorCategory.SELECTOR_NOT_FOUND, f"Shadow query failed: {e}"),
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# -- Network Interception --
|
|
694
|
+
|
|
695
|
+
async def intercept_requests(self, pattern: str = "*", *, action: str = "log") -> ActionResult:
|
|
696
|
+
"""Enable network request interception.
|
|
697
|
+
|
|
698
|
+
:param pattern: URL glob pattern to match (e.g. "**/api/**").
|
|
699
|
+
:param action: "log", "block", or "mock".
|
|
700
|
+
:returns: ActionResult with data=NetworkInterceptResult.
|
|
701
|
+
"""
|
|
702
|
+
start = time.monotonic()
|
|
703
|
+
if not self._page:
|
|
704
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
705
|
+
# Derive security level from action: block/mock modify traffic, log only observes
|
|
706
|
+
security_level = "dangerous" if action in ("block", "mock") else "sensitive"
|
|
707
|
+
params = {"pattern": pattern, "action": action}
|
|
708
|
+
sec = await self._check_facade_security("intercept_requests", params, security_level=security_level)
|
|
709
|
+
if sec is not None:
|
|
710
|
+
return sec
|
|
711
|
+
pattern = params["pattern"]
|
|
712
|
+
action = params["action"]
|
|
713
|
+
try:
|
|
714
|
+
intercepted = []
|
|
715
|
+
|
|
716
|
+
async def handle_route(route: Any) -> None:
|
|
717
|
+
req = route.request
|
|
718
|
+
intercepted.append({"url": req.url, "method": req.method})
|
|
719
|
+
if action == "block":
|
|
720
|
+
await route.abort()
|
|
721
|
+
else:
|
|
722
|
+
await route.continue_()
|
|
723
|
+
|
|
724
|
+
await self._page.engine_page.route(pattern, handle_route)
|
|
725
|
+
self._network_interceptors.append({"pattern": pattern, "action": action, "requests": intercepted})
|
|
726
|
+
|
|
727
|
+
return timed_action_result(
|
|
728
|
+
ok=True, start_ns=start,
|
|
729
|
+
data=NetworkInterceptResult(pattern=pattern, action=action),
|
|
730
|
+
)
|
|
731
|
+
except Exception as e:
|
|
732
|
+
return timed_action_result(
|
|
733
|
+
ok=False, start_ns=start,
|
|
734
|
+
error=ActionError(ErrorCategory.SECURITY, f"Interception failed: {e}"),
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
async def block_requests(self, pattern: str = "*") -> ActionResult:
|
|
738
|
+
"""Block all requests matching a URL pattern.
|
|
739
|
+
|
|
740
|
+
:param pattern: URL glob pattern to block.
|
|
741
|
+
"""
|
|
742
|
+
return await self.intercept_requests(pattern, action="block")
|
|
743
|
+
|
|
744
|
+
async def mock_response(self, pattern: str, body: str, *, content_type: str = "application/json", status: int = 200) -> ActionResult:
|
|
745
|
+
"""Mock a network response for matching requests.
|
|
746
|
+
|
|
747
|
+
:param pattern: URL glob pattern to match.
|
|
748
|
+
:param body: Response body to return.
|
|
749
|
+
:param content_type: Content-Type header.
|
|
750
|
+
:param status: HTTP status code.
|
|
751
|
+
"""
|
|
752
|
+
start = time.monotonic()
|
|
753
|
+
if not self._page:
|
|
754
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
755
|
+
params = {"pattern": pattern, "body": body, "content_type": content_type, "status": status}
|
|
756
|
+
sec = await self._check_facade_security("mock_response", params, security_level="dangerous")
|
|
757
|
+
if sec is not None:
|
|
758
|
+
return sec
|
|
759
|
+
pattern = params["pattern"]
|
|
760
|
+
body = params["body"]
|
|
761
|
+
content_type = params["content_type"]
|
|
762
|
+
status = params["status"]
|
|
763
|
+
try:
|
|
764
|
+
async def handle_mock(route: Any) -> None:
|
|
765
|
+
await route.fulfill(
|
|
766
|
+
status=status,
|
|
767
|
+
headers={"Content-Type": content_type},
|
|
768
|
+
body=body,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
await self._page.engine_page.route(pattern, handle_mock)
|
|
772
|
+
self._network_interceptors.append({"pattern": pattern, "action": "mock", "body": body})
|
|
773
|
+
|
|
774
|
+
return timed_action_result(
|
|
775
|
+
ok=True, start_ns=start,
|
|
776
|
+
data=NetworkInterceptResult(pattern=pattern, action="mock"),
|
|
777
|
+
)
|
|
778
|
+
except Exception as e:
|
|
779
|
+
return timed_action_result(
|
|
780
|
+
ok=False, start_ns=start,
|
|
781
|
+
error=ActionError(ErrorCategory.SECURITY, f"Mock failed: {e}"),
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
async def clear_interceptions(self) -> ActionResult:
|
|
785
|
+
"""Remove all network request interceptions."""
|
|
786
|
+
start = time.monotonic()
|
|
787
|
+
if not self._page:
|
|
788
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started"))
|
|
789
|
+
sec = await self._check_facade_security("clear_interceptions", {})
|
|
790
|
+
if sec is not None:
|
|
791
|
+
return sec
|
|
792
|
+
try:
|
|
793
|
+
await self._page.engine_page.unroute_all()
|
|
794
|
+
self._network_interceptors.clear()
|
|
795
|
+
return timed_action_result(ok=True, start_ns=start, data={"cleared": True})
|
|
796
|
+
except Exception as e:
|
|
797
|
+
return timed_action_result(ok=False, start_ns=start, error=ActionError(ErrorCategory.SECURITY, str(e)))
|
|
798
|
+
|
|
799
|
+
# -- Delegation --
|
|
800
|
+
|
|
801
|
+
async def delegate(self, tasks: list[str], *, max_concurrency: int = 4) -> DelegationResult:
|
|
802
|
+
if not self._session:
|
|
803
|
+
return DelegationResult(tasks=[], total_duration_ms=0, completed_count=0, failed_count=len(tasks), cancelled_count=0)
|
|
804
|
+
delegator = SubagentDelegator(
|
|
805
|
+
self._session, self._registry, self._llm_client,
|
|
806
|
+
max_concurrency=max_concurrency,
|
|
807
|
+
recovery_coordinator=self._coordinator,
|
|
808
|
+
budget_client=self._budget_client,
|
|
809
|
+
flow_logger=self._flow_logger,
|
|
810
|
+
security_manager=self._security_manager,
|
|
811
|
+
stealth_manager=self._stealth_manager,
|
|
812
|
+
)
|
|
813
|
+
return await delegator.delegate(tasks, max_concurrency=max_concurrency)
|
|
814
|
+
|
|
815
|
+
# -- Tool management --
|
|
816
|
+
|
|
817
|
+
def tools(self, *, toolset: Optional[str] = None) -> str:
|
|
818
|
+
return self._registry.build_tool_api_description(toolset=toolset)
|
|
819
|
+
|
|
820
|
+
def register_tool(self, func: Callable, *, toolsets: tuple[str, ...] = ()) -> None:
|
|
821
|
+
self._registry.register(func, toolsets=toolsets)
|
|
822
|
+
|
|
823
|
+
# -- Abort --
|
|
824
|
+
|
|
825
|
+
def abort(self) -> None:
|
|
826
|
+
self._abort_signal.set()
|
|
827
|
+
|
|
828
|
+
# -- Verification --
|
|
829
|
+
|
|
830
|
+
def configure_verification(self, config: Any = None) -> None:
|
|
831
|
+
from super_browser.verification import VisualVerifier
|
|
832
|
+
from super_browser.verification.types import VerifierConfig as VC
|
|
833
|
+
vconfig = config or VC()
|
|
834
|
+
verifier = VisualVerifier(
|
|
835
|
+
cdp=self._page.engine_page.cdp,
|
|
836
|
+
snapshot_provider=self._controller._snapshot_provider,
|
|
837
|
+
config=vconfig,
|
|
838
|
+
)
|
|
839
|
+
self._controller.enable_verification(verifier)
|
|
840
|
+
|
|
841
|
+
async def _check_facade_security(
|
|
842
|
+
self,
|
|
843
|
+
action: str,
|
|
844
|
+
params: dict[str, Any],
|
|
845
|
+
*,
|
|
846
|
+
url: str = "",
|
|
847
|
+
security_level: str = "sensitive",
|
|
848
|
+
) -> ActionResult | None:
|
|
849
|
+
"""Enforce configured security policy on a direct facade call.
|
|
850
|
+
|
|
851
|
+
Returns ``None`` when the action is allowed (or security is disabled).
|
|
852
|
+
Returns an ``ActionResult`` with ``ErrorCategory.SECURITY`` when blocked.
|
|
853
|
+
|
|
854
|
+
The *params* dict is passed by reference so that the security manager
|
|
855
|
+
can redact values in-place before the caller uses them.
|
|
856
|
+
"""
|
|
857
|
+
if self._security_manager is None:
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
if not url:
|
|
861
|
+
try:
|
|
862
|
+
url = str(self._page.url) if self._page and hasattr(self._page, "url") else ""
|
|
863
|
+
except Exception:
|
|
864
|
+
url = ""
|
|
865
|
+
|
|
866
|
+
from super_browser.security.types import SecurityLevel
|
|
867
|
+
level = SecurityLevel(security_level)
|
|
868
|
+
sec_result = await self._security_manager.check_action(
|
|
869
|
+
action, params, url, level,
|
|
870
|
+
)
|
|
871
|
+
if not sec_result.passed:
|
|
872
|
+
return action_result(
|
|
873
|
+
ok=False,
|
|
874
|
+
error=ActionError(
|
|
875
|
+
ErrorCategory.SECURITY,
|
|
876
|
+
f"Security check failed: {sec_result.blocked_by}",
|
|
877
|
+
),
|
|
878
|
+
)
|
|
879
|
+
return None
|
|
880
|
+
|
|
881
|
+
def _make_controller_wrapper(self, method_name: str):
|
|
882
|
+
"""Create a late-binding wrapper for a controller method.
|
|
883
|
+
|
|
884
|
+
The wrapper dereferences ``self._controller`` at call time, so after
|
|
885
|
+
:meth:`_attach_page` replaces the controller, the registered tool still
|
|
886
|
+
routes to the current controller and page.
|
|
887
|
+
|
|
888
|
+
Signature and docstring are copied from the current controller method
|
|
889
|
+
via :func:`functools.wraps` so the registry can introspect parameters.
|
|
890
|
+
No security check is added — AgentLoop._dispatch_action handles that.
|
|
891
|
+
"""
|
|
892
|
+
import functools
|
|
893
|
+
|
|
894
|
+
original = getattr(self._controller, method_name)
|
|
895
|
+
|
|
896
|
+
@functools.wraps(original)
|
|
897
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
898
|
+
return await getattr(self._controller, method_name)(*args, **kwargs)
|
|
899
|
+
|
|
900
|
+
wrapper.__name__ = method_name
|
|
901
|
+
return wrapper
|
|
902
|
+
|
|
903
|
+
def _register_builtin_tools(self) -> None:
|
|
904
|
+
"""Register built-in browser and facade tools into the registry.
|
|
905
|
+
|
|
906
|
+
Called during :meth:`start` after the controller is created.
|
|
907
|
+
Does not overwrite tools already registered by the user.
|
|
908
|
+
"""
|
|
909
|
+
if not self._controller:
|
|
910
|
+
return
|
|
911
|
+
|
|
912
|
+
# Controller-level interaction tools.
|
|
913
|
+
# Use late-binding wrappers so that after _attach_page() replaces
|
|
914
|
+
# self._controller, the registered tools still route to the current
|
|
915
|
+
# controller. Raw bound methods would capture the old controller
|
|
916
|
+
# instance and act on a stale page after tab switches.
|
|
917
|
+
for name in ("click", "fill", "select", "hover", "drag", "scroll", "keypress"):
|
|
918
|
+
method = getattr(self._controller, name, None)
|
|
919
|
+
if method is not None and self._registry.get(name) is None:
|
|
920
|
+
self._registry.register(self._make_controller_wrapper(name))
|
|
921
|
+
|
|
922
|
+
# Facade-level tools
|
|
923
|
+
# navigate: register _navigate_impl via a closure (no security check)
|
|
924
|
+
# because AgentLoop._dispatch_action already enforces security.
|
|
925
|
+
# The public navigate() method keeps its own check for direct SDK calls.
|
|
926
|
+
if self._registry.get("navigate") is None:
|
|
927
|
+
facade_ref = self
|
|
928
|
+
|
|
929
|
+
async def navigate(url: str, *, wait_until: str = "domcontentloaded") -> ActionResult:
|
|
930
|
+
"""Navigate to a URL (security enforced by AgentLoop dispatcher)."""
|
|
931
|
+
return await facade_ref._navigate_impl(url, wait_until=wait_until)
|
|
932
|
+
|
|
933
|
+
self._registry.register(navigate)
|
|
934
|
+
|
|
935
|
+
# extract, observe: no facade security check, safe to register directly
|
|
936
|
+
for name in ("extract", "observe"):
|
|
937
|
+
method = getattr(self, name, None)
|
|
938
|
+
if method is not None and self._registry.get(name) is None:
|
|
939
|
+
self._registry.register(method)
|
|
940
|
+
|
|
941
|
+
def _configure_verification(self) -> None:
|
|
942
|
+
if not self._config.agent.enable_verification:
|
|
943
|
+
return
|
|
944
|
+
try:
|
|
945
|
+
from super_browser.verification import VisualVerifier
|
|
946
|
+
from super_browser.verification.types import VerifierConfig as VC
|
|
947
|
+
verifier = VisualVerifier(
|
|
948
|
+
cdp=self._page.engine_page.cdp,
|
|
949
|
+
snapshot_provider=self._controller._snapshot_provider,
|
|
950
|
+
config=VC(),
|
|
951
|
+
)
|
|
952
|
+
self._controller.enable_verification(verifier)
|
|
953
|
+
except Exception:
|
|
954
|
+
pass # verification optional — fail silently
|
|
955
|
+
|
|
956
|
+
def _configure_vision(self) -> None:
|
|
957
|
+
if not self._config.agent.enable_vision:
|
|
958
|
+
return
|
|
959
|
+
from pathlib import Path
|
|
960
|
+
|
|
961
|
+
from super_browser.vision import VisionCache, VisionController, VisionProviderFactory
|
|
962
|
+
factory = VisionProviderFactory.from_env()
|
|
963
|
+
cache_dir_str = self._config.agent.vision_cache_dir
|
|
964
|
+
cache_dir = Path(cache_dir_str) if cache_dir_str else None
|
|
965
|
+
cache = VisionCache(cache_dir=cache_dir)
|
|
966
|
+
self._vision_controller = VisionController(factory=factory, cache=cache)
|
|
967
|
+
self._controller._vision_controller = self._vision_controller
|
|
968
|
+
|
|
969
|
+
def _configure_stealth(self) -> None:
|
|
970
|
+
if not self._config.agent.enable_stealth:
|
|
971
|
+
return
|
|
972
|
+
from super_browser.stealth import StealthManager
|
|
973
|
+
stealth_config = self._config.stealth
|
|
974
|
+
stealth_bridge = getattr(self._page.engine_page, "stealth_bridge", None)
|
|
975
|
+
self._stealth_manager = StealthManager(
|
|
976
|
+
stealth_config,
|
|
977
|
+
stealth_bridge=stealth_bridge,
|
|
978
|
+
cdp=self._page.engine_page.cdp if stealth_bridge is None else None,
|
|
979
|
+
page=self._page.engine_page,
|
|
980
|
+
)
|
|
981
|
+
self._loop_stealth = self._stealth_manager
|
|
982
|
+
|
|
983
|
+
def _configure_skills(self) -> None:
|
|
984
|
+
if not self._config.agent.enable_skills:
|
|
985
|
+
return
|
|
986
|
+
from pathlib import Path
|
|
987
|
+
|
|
988
|
+
from super_browser.skills import SkillRegistry
|
|
989
|
+
skills_dir_str = self._config.agent.skills_dir
|
|
990
|
+
skills_dir = Path(skills_dir_str) if skills_dir_str else None
|
|
991
|
+
self._skill_registry = SkillRegistry(skills_dir=skills_dir)
|
|
992
|
+
if self._page and hasattr(self._page, "cdp"):
|
|
993
|
+
self._skill_registry.set_cdp(self._page.engine_page.cdp)
|
|
994
|
+
|
|
995
|
+
async def learn_from_trajectory(
|
|
996
|
+
self, domain: str, task_description: str, actions_taken: list[str],
|
|
997
|
+
selectors_used: dict[str, str], *, preferred_tier: Optional[dict[str, str]] = None,
|
|
998
|
+
) -> Any:
|
|
999
|
+
if not self._skill_registry:
|
|
1000
|
+
return None
|
|
1001
|
+
return await self._skill_registry.learn_from_trajectory(
|
|
1002
|
+
domain, task_description, actions_taken, selectors_used,
|
|
1003
|
+
preferred_tier=preferred_tier,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
# -- Session Persistence --
|
|
1007
|
+
|
|
1008
|
+
async def save_session(self, path: str) -> ActionResult:
|
|
1009
|
+
"""Save cookies and session state to a JSON file.
|
|
1010
|
+
|
|
1011
|
+
Serializes all browser cookies via StealthBridge along with
|
|
1012
|
+
metadata (URL, timestamp, version). Works across all backends.
|
|
1013
|
+
|
|
1014
|
+
:param path: File path to write the session JSON.
|
|
1015
|
+
:returns: ActionResult with data containing the session metadata.
|
|
1016
|
+
"""
|
|
1017
|
+
import json
|
|
1018
|
+
start = time.monotonic()
|
|
1019
|
+
if not self._page:
|
|
1020
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started."))
|
|
1021
|
+
stealth_bridge = getattr(self._page.engine_page, "stealth_bridge", None)
|
|
1022
|
+
if stealth_bridge is None:
|
|
1023
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.VALIDATION, "No stealth bridge available for cookie access."))
|
|
1024
|
+
params = {"path": path}
|
|
1025
|
+
sec = await self._check_facade_security("save_session", params, security_level="dangerous")
|
|
1026
|
+
if sec is not None:
|
|
1027
|
+
return sec
|
|
1028
|
+
path = params["path"]
|
|
1029
|
+
try:
|
|
1030
|
+
cookies = await stealth_bridge.get_all_cookies()
|
|
1031
|
+
session_data = {
|
|
1032
|
+
"version": "1.0",
|
|
1033
|
+
"timestamp": time.time(),
|
|
1034
|
+
"url": str(self._page.url) if hasattr(self._page, "url") else "",
|
|
1035
|
+
"cookies": cookies,
|
|
1036
|
+
}
|
|
1037
|
+
from pathlib import Path as _Path
|
|
1038
|
+
target = _Path(path)
|
|
1039
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1040
|
+
target.write_text(json.dumps(session_data, indent=2), encoding="utf-8")
|
|
1041
|
+
return timed_action_result(
|
|
1042
|
+
ok=True,
|
|
1043
|
+
start_ns=start,
|
|
1044
|
+
data={"path": str(target), "cookie_count": len(cookies)},
|
|
1045
|
+
method=ActionMethod.SELECTOR,
|
|
1046
|
+
)
|
|
1047
|
+
except Exception as exc:
|
|
1048
|
+
return timed_action_result(
|
|
1049
|
+
ok=False,
|
|
1050
|
+
start_ns=start,
|
|
1051
|
+
error=ActionError(ErrorCategory.BROWSER_CRASH, f"save_session failed: {exc}"),
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
async def load_session(self, path: str) -> ActionResult:
|
|
1055
|
+
"""Load cookies and session state from a JSON file.
|
|
1056
|
+
|
|
1057
|
+
Reads a session JSON previously saved by :meth:`save_session`,
|
|
1058
|
+
validates the version, and restores cookies via StealthBridge.
|
|
1059
|
+
|
|
1060
|
+
:param path: File path to read the session JSON from.
|
|
1061
|
+
:returns: ActionResult with data containing restored cookie count.
|
|
1062
|
+
"""
|
|
1063
|
+
import json
|
|
1064
|
+
start = time.monotonic()
|
|
1065
|
+
if not self._page:
|
|
1066
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.BROWSER_CRASH, "Browser not started."))
|
|
1067
|
+
stealth_bridge = getattr(self._page.engine_page, "stealth_bridge", None)
|
|
1068
|
+
if stealth_bridge is None:
|
|
1069
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.VALIDATION, "No stealth bridge available for cookie access."))
|
|
1070
|
+
params = {"path": path}
|
|
1071
|
+
sec = await self._check_facade_security("load_session", params, security_level="dangerous")
|
|
1072
|
+
if sec is not None:
|
|
1073
|
+
return sec
|
|
1074
|
+
path = params["path"]
|
|
1075
|
+
try:
|
|
1076
|
+
from pathlib import Path as _Path
|
|
1077
|
+
source = _Path(path)
|
|
1078
|
+
if not source.exists():
|
|
1079
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.VALIDATION, f"Session file not found: {path}"))
|
|
1080
|
+
session_data = json.loads(source.read_text(encoding="utf-8"))
|
|
1081
|
+
version = session_data.get("version", "")
|
|
1082
|
+
if version != "1.0":
|
|
1083
|
+
return action_result(ok=False, error=ActionError(ErrorCategory.VALIDATION, f"Unsupported session format version: {version}"))
|
|
1084
|
+
cookies = session_data.get("cookies", [])
|
|
1085
|
+
if not cookies:
|
|
1086
|
+
return timed_action_result(
|
|
1087
|
+
ok=True,
|
|
1088
|
+
start_ns=start,
|
|
1089
|
+
data={"path": str(source), "cookie_count": 0, "message": "No cookies to restore."},
|
|
1090
|
+
method=ActionMethod.SELECTOR,
|
|
1091
|
+
)
|
|
1092
|
+
await stealth_bridge.set_cookies(cookies)
|
|
1093
|
+
return timed_action_result(
|
|
1094
|
+
ok=True,
|
|
1095
|
+
start_ns=start,
|
|
1096
|
+
data={"path": str(source), "cookie_count": len(cookies)},
|
|
1097
|
+
method=ActionMethod.SELECTOR,
|
|
1098
|
+
)
|
|
1099
|
+
except json.JSONDecodeError as exc:
|
|
1100
|
+
return timed_action_result(
|
|
1101
|
+
ok=False,
|
|
1102
|
+
start_ns=start,
|
|
1103
|
+
error=ActionError(ErrorCategory.VALIDATION, f"Invalid JSON in session file: {exc}"),
|
|
1104
|
+
)
|
|
1105
|
+
except Exception as exc:
|
|
1106
|
+
return timed_action_result(
|
|
1107
|
+
ok=False,
|
|
1108
|
+
start_ns=start,
|
|
1109
|
+
error=ActionError(ErrorCategory.BROWSER_CRASH, f"load_session failed: {exc}"),
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
# -- Recording --
|
|
1113
|
+
|
|
1114
|
+
def enable_recording(self, *, max_screenshots: int = 100) -> None:
|
|
1115
|
+
"""Enable session recording. Call before or after start()."""
|
|
1116
|
+
from super_browser.events.bus import EventBus
|
|
1117
|
+
from super_browser.recording.recorder import SessionRecorder
|
|
1118
|
+
|
|
1119
|
+
if self._event_bus is None:
|
|
1120
|
+
self._event_bus = EventBus()
|
|
1121
|
+
cdp = None
|
|
1122
|
+
if self._page and hasattr(self._page, "engine_page"):
|
|
1123
|
+
cdp = self._page.engine_page.cdp
|
|
1124
|
+
self._recorder = SessionRecorder(
|
|
1125
|
+
self._event_bus, cdp, max_screenshots=max_screenshots,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
@property
|
|
1129
|
+
def recording(self) -> Any:
|
|
1130
|
+
"""Access the active SessionRecorder, or None if recording is not enabled."""
|
|
1131
|
+
return self._recorder
|
|
1132
|
+
|
|
1133
|
+
@property
|
|
1134
|
+
def event_bus(self) -> Any:
|
|
1135
|
+
"""Access the EventBus, or None if not initialized."""
|
|
1136
|
+
return self._event_bus
|
|
1137
|
+
|
|
1138
|
+
async def replay(self, path: str, *, delay_ms: float = 100) -> ActionResult:
|
|
1139
|
+
"""Load a recording from *path* and replay it against this browser.
|
|
1140
|
+
|
|
1141
|
+
:param path: Path to a recording JSON file.
|
|
1142
|
+
:param delay_ms: Delay between actions in milliseconds.
|
|
1143
|
+
:returns: ActionResult with data=ReplayReport.
|
|
1144
|
+
"""
|
|
1145
|
+
start = time.monotonic()
|
|
1146
|
+
params = {"path": path}
|
|
1147
|
+
sec = await self._check_facade_security("replay", params, security_level="dangerous")
|
|
1148
|
+
if sec is not None:
|
|
1149
|
+
return sec
|
|
1150
|
+
path = params["path"]
|
|
1151
|
+
try:
|
|
1152
|
+
from super_browser.recording.persistence import load as load_recording
|
|
1153
|
+
from super_browser.recording.replayer import RecordingReplayer
|
|
1154
|
+
|
|
1155
|
+
recording = load_recording(path)
|
|
1156
|
+
replayer = RecordingReplayer(self)
|
|
1157
|
+
report = await replayer.replay(recording, delay_ms=delay_ms)
|
|
1158
|
+
return timed_action_result(
|
|
1159
|
+
ok=True,
|
|
1160
|
+
start_ns=start,
|
|
1161
|
+
data=report,
|
|
1162
|
+
)
|
|
1163
|
+
except Exception as exc:
|
|
1164
|
+
return timed_action_result(
|
|
1165
|
+
ok=False,
|
|
1166
|
+
start_ns=start,
|
|
1167
|
+
error=ActionError(ErrorCategory.BROWSER_CRASH, f"Replay failed: {exc}"),
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
@property
|
|
1171
|
+
def is_running(self) -> bool:
|
|
1172
|
+
return self._running
|
|
1173
|
+
|
|
1174
|
+
# -- Stealth Backend --
|
|
1175
|
+
|
|
1176
|
+
@property
|
|
1177
|
+
def stealth_backend(self) -> str:
|
|
1178
|
+
"""Name of the active stealth backend ('cloak' or 'patchright')."""
|
|
1179
|
+
if self._session is not None:
|
|
1180
|
+
return self._session.stealth_backend
|
|
1181
|
+
return "patchright"
|
|
1182
|
+
|
|
1183
|
+
@property
|
|
1184
|
+
def cloak_config(self) -> Any:
|
|
1185
|
+
"""The CloakConfig if CloakBrowser is available, else None."""
|
|
1186
|
+
if self._session is not None and self._engine is not None:
|
|
1187
|
+
return self._engine.cloak_config
|
|
1188
|
+
return None
|
|
1189
|
+
|
|
1190
|
+
# -- Memory --
|
|
1191
|
+
|
|
1192
|
+
def enable_memory(
|
|
1193
|
+
self,
|
|
1194
|
+
*,
|
|
1195
|
+
memory_dir: str = "~/.config/super-browser/memory",
|
|
1196
|
+
ttl_days: int = 30,
|
|
1197
|
+
) -> None:
|
|
1198
|
+
"""Enable per-domain memory persistence (opt-in).
|
|
1199
|
+
|
|
1200
|
+
Call before or after :meth:`start`.
|
|
1201
|
+
"""
|
|
1202
|
+
from super_browser.memory.integration import create_memory_store
|
|
1203
|
+
self._memory_store = create_memory_store(
|
|
1204
|
+
memory_enabled=True,
|
|
1205
|
+
memory_dir=memory_dir,
|
|
1206
|
+
ttl_days=ttl_days,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
@property
|
|
1210
|
+
def memory(self) -> Optional[MemoryStore]:
|
|
1211
|
+
"""Access the active MemoryStore, or None if memory is not enabled."""
|
|
1212
|
+
return self._memory_store
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
class ConfigurationError(Exception):
|
|
1216
|
+
"""Raised when SuperBrowser is used without required configuration."""
|