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.
Files changed (217) hide show
  1. super_browser/__init__.py +17 -0
  2. super_browser/agent/__init__.py +37 -0
  3. super_browser/agent/config.py +18 -0
  4. super_browser/agent/debug.py +201 -0
  5. super_browser/agent/delegator.py +145 -0
  6. super_browser/agent/facade.py +1216 -0
  7. super_browser/agent/llm/__init__.py +7 -0
  8. super_browser/agent/llm/anthropic_client.py +326 -0
  9. super_browser/agent/llm/browser_transport.py +352 -0
  10. super_browser/agent/llm/budget_aware.py +195 -0
  11. super_browser/agent/llm/factory.py +89 -0
  12. super_browser/agent/llm/openai_client.py +409 -0
  13. super_browser/agent/llm/protocol.py +73 -0
  14. super_browser/agent/loop.py +647 -0
  15. super_browser/agent/loop_detector.py +75 -0
  16. super_browser/agent/plugins.py +38 -0
  17. super_browser/agent/registry.py +218 -0
  18. super_browser/agent/router.py +99 -0
  19. super_browser/agent/structured_logging.py +76 -0
  20. super_browser/agent/types.py +184 -0
  21. super_browser/behavioral/__init__.py +36 -0
  22. super_browser/behavioral/bezier.py +64 -0
  23. super_browser/behavioral/dwell.py +162 -0
  24. super_browser/behavioral/fitts.py +35 -0
  25. super_browser/behavioral/gauss.py +100 -0
  26. super_browser/behavioral/keyboard.py +183 -0
  27. super_browser/behavioral/mouse.py +198 -0
  28. super_browser/behavioral/navigation.py +144 -0
  29. super_browser/behavioral/orchestrator.py +221 -0
  30. super_browser/behavioral/prng.py +27 -0
  31. super_browser/behavioral/qwerty.py +163 -0
  32. super_browser/behavioral/scroll.py +113 -0
  33. super_browser/behavioral/session_seed.py +85 -0
  34. super_browser/behavioral/types.py +62 -0
  35. super_browser/browser/__init__.py +15 -0
  36. super_browser/browser/backends/__init__.py +43 -0
  37. super_browser/browser/backends/cdp_backend.py +613 -0
  38. super_browser/browser/backends/patchright_backend.py +351 -0
  39. super_browser/browser/backends/playwright_backend.py +368 -0
  40. super_browser/browser/backends/selenium_backend.py +567 -0
  41. super_browser/browser/cdp.py +241 -0
  42. super_browser/browser/cloak_backend.py +162 -0
  43. super_browser/browser/cloud.py +265 -0
  44. super_browser/browser/config.py +48 -0
  45. super_browser/browser/discovery.py +101 -0
  46. super_browser/browser/engine.py +326 -0
  47. super_browser/browser/fetch.py +384 -0
  48. super_browser/browser/injectors/__init__.py +46 -0
  49. super_browser/browser/injectors/bidi_injector.py +39 -0
  50. super_browser/browser/injectors/cdp_injector.py +83 -0
  51. super_browser/browser/injectors/page_injector.py +71 -0
  52. super_browser/browser/page.py +96 -0
  53. super_browser/browser/session.py +295 -0
  54. super_browser/browser/shutdown.py +75 -0
  55. super_browser/browser/tabs.py +140 -0
  56. super_browser/budget/__init__.py +40 -0
  57. super_browser/budget/cascade.py +142 -0
  58. super_browser/budget/client.py +132 -0
  59. super_browser/budget/compressor.py +218 -0
  60. super_browser/budget/cost_estimator.py +67 -0
  61. super_browser/budget/credential_pool.py +282 -0
  62. super_browser/budget/governor.py +279 -0
  63. super_browser/budget/types.py +227 -0
  64. super_browser/cli/__init__.py +214 -0
  65. super_browser/cli/commands.py +235 -0
  66. super_browser/cli/interactive.py +85 -0
  67. super_browser/cli/script.py +266 -0
  68. super_browser/cli.py +279 -0
  69. super_browser/config.py +479 -0
  70. super_browser/events/__init__.py +31 -0
  71. super_browser/events/bus.py +110 -0
  72. super_browser/events/types.py +42 -0
  73. super_browser/interaction/__init__.py +32 -0
  74. super_browser/interaction/cache.py +180 -0
  75. super_browser/interaction/controller.py +648 -0
  76. super_browser/interaction/decorator.py +41 -0
  77. super_browser/interaction/presets.py +117 -0
  78. super_browser/interaction/recovery.py +48 -0
  79. super_browser/interaction/snapshot.py +153 -0
  80. super_browser/interaction/types.py +135 -0
  81. super_browser/interaction/vision.py +56 -0
  82. super_browser/memory/__init__.py +6 -0
  83. super_browser/memory/integration.py +85 -0
  84. super_browser/memory/store.py +241 -0
  85. super_browser/memory/types.py +57 -0
  86. super_browser/plugins/__init__.py +10 -0
  87. super_browser/plugins/decorators.py +33 -0
  88. super_browser/plugins/hooks.py +28 -0
  89. super_browser/py.typed +0 -0
  90. super_browser/recording/__init__.py +19 -0
  91. super_browser/recording/persistence.py +49 -0
  92. super_browser/recording/recorder.py +189 -0
  93. super_browser/recording/replayer.py +218 -0
  94. super_browser/recording/report.py +123 -0
  95. super_browser/recording/types.py +124 -0
  96. super_browser/recovery/__init__.py +67 -0
  97. super_browser/recovery/checkpoint.py +317 -0
  98. super_browser/recovery/classifier.py +235 -0
  99. super_browser/recovery/coordinator.py +240 -0
  100. super_browser/recovery/event_bus.py +67 -0
  101. super_browser/recovery/format_validator.py +172 -0
  102. super_browser/recovery/reflection.py +109 -0
  103. super_browser/recovery/retry_tracker.py +81 -0
  104. super_browser/recovery/session_recovery.py +248 -0
  105. super_browser/recovery/types.py +178 -0
  106. super_browser/recovery/watchdogs.py +251 -0
  107. super_browser/results/__init__.py +54 -0
  108. super_browser/results/output.py +154 -0
  109. super_browser/results/typed.py +165 -0
  110. super_browser/results/types.py +361 -0
  111. super_browser/results/validation.py +126 -0
  112. super_browser/security/__init__.py +75 -0
  113. super_browser/security/action_redaction.py +127 -0
  114. super_browser/security/approval.py +130 -0
  115. super_browser/security/credential_vault.py +203 -0
  116. super_browser/security/domain_filter.py +56 -0
  117. super_browser/security/gate.py +114 -0
  118. super_browser/security/injection.py +162 -0
  119. super_browser/security/manager.py +185 -0
  120. super_browser/security/policy.py +69 -0
  121. super_browser/security/redactor.py +151 -0
  122. super_browser/security/types.py +215 -0
  123. super_browser/session/__init__.py +1 -0
  124. super_browser/session/proxy.py +153 -0
  125. super_browser/skills/__init__.py +31 -0
  126. super_browser/skills/activation.py +38 -0
  127. super_browser/skills/markdown.py +109 -0
  128. super_browser/skills/registry.py +335 -0
  129. super_browser/skills/types.py +123 -0
  130. super_browser/stealth/__init__.py +94 -0
  131. super_browser/stealth/action_policy.py +88 -0
  132. super_browser/stealth/captcha.py +318 -0
  133. super_browser/stealth/challenges/__init__.py +41 -0
  134. super_browser/stealth/challenges/cache.py +293 -0
  135. super_browser/stealth/challenges/pow.py +242 -0
  136. super_browser/stealth/challenges/turnstile.py +259 -0
  137. super_browser/stealth/consistency/__init__.py +28 -0
  138. super_browser/stealth/consistency/dag.py +142 -0
  139. super_browser/stealth/consistency/derive.py +282 -0
  140. super_browser/stealth/consistency/errors.py +40 -0
  141. super_browser/stealth/consistency/inject.py +429 -0
  142. super_browser/stealth/consistency/inject_delivery.py +283 -0
  143. super_browser/stealth/consistency/matrix.py +106 -0
  144. super_browser/stealth/consistency/prng.py +115 -0
  145. super_browser/stealth/consistency/rule.py +64 -0
  146. super_browser/stealth/consistency/rules/__init__.py +40 -0
  147. super_browser/stealth/consistency/rules/audio.py +37 -0
  148. super_browser/stealth/consistency/rules/behavior.py +142 -0
  149. super_browser/stealth/consistency/rules/fonts.py +49 -0
  150. super_browser/stealth/consistency/rules/gpu.py +116 -0
  151. super_browser/stealth/consistency/rules/locale.py +66 -0
  152. super_browser/stealth/consistency/rules/navigator.py +70 -0
  153. super_browser/stealth/consistency/rules/screen.py +87 -0
  154. super_browser/stealth/consistency/rules/user_agent.py +121 -0
  155. super_browser/stealth/diagnostics.py +204 -0
  156. super_browser/stealth/ejecta/__init__.py +20 -0
  157. super_browser/stealth/ejecta/audio.py +182 -0
  158. super_browser/stealth/ejecta/browser_apis.py +225 -0
  159. super_browser/stealth/ejecta/canvas.py +210 -0
  160. super_browser/stealth/ejecta/config.py +48 -0
  161. super_browser/stealth/ejecta/registry.py +55 -0
  162. super_browser/stealth/ejecta/timing.py +159 -0
  163. super_browser/stealth/ejecta/types.py +30 -0
  164. super_browser/stealth/ejecta/webrtc.py +120 -0
  165. super_browser/stealth/fingerprint_scanner.py +187 -0
  166. super_browser/stealth/fingerprint_score.py +122 -0
  167. super_browser/stealth/headers.py +115 -0
  168. super_browser/stealth/human.py +419 -0
  169. super_browser/stealth/human_config.py +158 -0
  170. super_browser/stealth/ip_reputation.py +330 -0
  171. super_browser/stealth/manager.py +463 -0
  172. super_browser/stealth/profiles/__init__.py +145 -0
  173. super_browser/stealth/profiles/data/linux-chrome-stable.json +98 -0
  174. super_browser/stealth/profiles/data/macos-chrome-stable.json +130 -0
  175. super_browser/stealth/profiles/data/macos-m4-chrome-stable.json +132 -0
  176. super_browser/stealth/profiles/data/windows-chrome-stable.json +103 -0
  177. super_browser/stealth/profiles/host_detect.py +32 -0
  178. super_browser/stealth/profiles/schema.py +165 -0
  179. super_browser/stealth/proxy.py +86 -0
  180. super_browser/stealth/proxy_pool.py +490 -0
  181. super_browser/stealth/report.py +111 -0
  182. super_browser/stealth/scoring.py +54 -0
  183. super_browser/stealth/tls_baselines.json +36 -0
  184. super_browser/stealth/tls_fingerprint.py +494 -0
  185. super_browser/stealth/types.py +179 -0
  186. super_browser/stealth/user_agent_pool.py +148 -0
  187. super_browser/stealth/validation/__init__.py +13 -0
  188. super_browser/stealth/validation/checks.py +334 -0
  189. super_browser/stealth/validation/harness.py +161 -0
  190. super_browser/stealth/validation/report.py +31 -0
  191. super_browser/stealth/validation/suite.py +49 -0
  192. super_browser/testing.py +422 -0
  193. super_browser/tracing/__init__.py +29 -0
  194. super_browser/tracing/cost_analytics.py +49 -0
  195. super_browser/tracing/flow_logger.py +283 -0
  196. super_browser/tracing/middleware.py +42 -0
  197. super_browser/tracing/session_db.py +243 -0
  198. super_browser/tracing/sinks.py +166 -0
  199. super_browser/tracing/types.py +175 -0
  200. super_browser/verification/__init__.py +39 -0
  201. super_browser/verification/ax_diff.py +65 -0
  202. super_browser/verification/hasher.py +154 -0
  203. super_browser/verification/types.py +153 -0
  204. super_browser/verification/verifier.py +331 -0
  205. super_browser/vision/__init__.py +49 -0
  206. super_browser/vision/cache.py +178 -0
  207. super_browser/vision/controller.py +373 -0
  208. super_browser/vision/coords.py +57 -0
  209. super_browser/vision/factory.py +116 -0
  210. super_browser/vision/ocr.py +140 -0
  211. super_browser/vision/providers.py +348 -0
  212. super_browser/vision/types.py +110 -0
  213. superbrowser_sdk-2.0.0.dist-info/METADATA +562 -0
  214. superbrowser_sdk-2.0.0.dist-info/RECORD +217 -0
  215. superbrowser_sdk-2.0.0.dist-info/WHEEL +4 -0
  216. superbrowser_sdk-2.0.0.dist-info/entry_points.txt +2 -0
  217. 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."""