vfa-mcp 1.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.
app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Agente Visual & Functional Auditor (VFA): modulos de configuracion, navegador, captura, vision, semantica y tools MCP."""
@@ -0,0 +1 @@
1
+ """Adaptadores de infraestructura (navegador y LLM)."""
@@ -0,0 +1,30 @@
1
+ """Adaptadores de navegador basados en Playwright."""
2
+
3
+ from app.adapters.browser.capture import ErrorCapture, attach_capture
4
+ from app.adapters.browser.playwright_adapter import (
5
+ PlaywrightBrowserAdapter,
6
+ PlaywrightSession,
7
+ is_session_dead,
8
+ )
9
+ from app.adapters.browser.session_pool import (
10
+ SessionPool,
11
+ get_current_session_id,
12
+ get_session_pool,
13
+ new_session_id,
14
+ reset_current_session_id,
15
+ set_current_session_id,
16
+ )
17
+
18
+ __all__ = [
19
+ "ErrorCapture",
20
+ "PlaywrightBrowserAdapter",
21
+ "PlaywrightSession",
22
+ "SessionPool",
23
+ "attach_capture",
24
+ "get_current_session_id",
25
+ "get_session_pool",
26
+ "is_session_dead",
27
+ "new_session_id",
28
+ "reset_current_session_id",
29
+ "set_current_session_id",
30
+ ]
@@ -0,0 +1,102 @@
1
+ """Capturador de errores de runtime: console.error, pageerror y HTTP >= 400."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, List
6
+
7
+ from app.domain.models import CONSOLE, HTTP, PAGE_ERROR, RuntimeErrorEvent
8
+
9
+ _MAX_EVENTS = 200
10
+
11
+
12
+ class ErrorCapture:
13
+ """Acumula `RuntimeErrorEvent` de una pagina Playwright.
14
+
15
+ Es deliberadamente agnostico del driver: solo necesita objetos con
16
+ atributos `type`, `text` o `message`, lo que permite usar dobles en los
17
+ tests sin levantar un navegador real.
18
+ """
19
+
20
+ def __init__(self, max_events: int = _MAX_EVENTS) -> None:
21
+ self._events: List[RuntimeErrorEvent] = []
22
+ self._max_events = max_events
23
+
24
+ # ---------------------------------------------------------------- registro
25
+ def _push(self, event: RuntimeErrorEvent) -> None:
26
+ if len(self._events) < self._max_events:
27
+ self._events.append(event)
28
+
29
+ def record_console(self, message: Any) -> None:
30
+ """Registra un mensaje de consola solo si es un error."""
31
+ msg_type = str(getattr(message, "type", "") or "").lower()
32
+ if msg_type not in ("error", "warning", "assert"):
33
+ return
34
+ if msg_type != "error":
35
+ return
36
+ text = str(getattr(message, "text", "") or getattr(message, "message", ""))
37
+ location = getattr(message, "location", None) or {}
38
+ url = ""
39
+ if isinstance(location, dict):
40
+ url = str(location.get("url") or "")
41
+ self._push(RuntimeErrorEvent(kind=CONSOLE, message=text, url=url))
42
+
43
+ def record_page_error(self, error: Any) -> None:
44
+ """Registra una excepcion JavaScript no capturada."""
45
+ text = str(getattr(error, "message", None) or error)
46
+ self._push(RuntimeErrorEvent(kind=PAGE_ERROR, message=text))
47
+
48
+ def record_response(self, response: Any) -> None:
49
+ """Registra respuestas HTTP con status >= 400."""
50
+ status = getattr(response, "status", None)
51
+ try:
52
+ status_int = int(status)
53
+ except (TypeError, ValueError):
54
+ return
55
+ if status_int < 400:
56
+ return
57
+ url = str(getattr(response, "url", "") or "")
58
+ self._push(
59
+ RuntimeErrorEvent(
60
+ kind=HTTP,
61
+ message=f"HTTP {status_int} {url}".strip(),
62
+ url=url,
63
+ status=status_int,
64
+ )
65
+ )
66
+
67
+ # ---------------------------------------------------------------- consulta
68
+ def events(self) -> List[RuntimeErrorEvent]:
69
+ return list(self._events)
70
+
71
+ def as_dicts(self) -> List[dict]:
72
+ return [event.to_dict() for event in self._events]
73
+
74
+ def drain(self) -> List[RuntimeErrorEvent]:
75
+ """Devuelve y limpia los eventos acumulados."""
76
+ pending = self._events
77
+ self._events = []
78
+ return pending
79
+
80
+ def clear(self) -> None:
81
+ self._events = []
82
+
83
+ def __len__(self) -> int:
84
+ return len(self._events)
85
+
86
+
87
+ def attach_capture(page: Any, capture: ErrorCapture) -> ErrorCapture:
88
+ """Engancha los listeners de Playwright a un `ErrorCapture`."""
89
+ on = getattr(page, "on", None)
90
+ if callable(on):
91
+ on("console", capture.record_console)
92
+ on("pageerror", capture.record_page_error)
93
+ on("response", capture.record_response)
94
+ return capture
95
+
96
+
97
+ def merge_events(*groups: Iterable[RuntimeErrorEvent]) -> List[RuntimeErrorEvent]:
98
+ """Fusiona varios grupos de eventos preservando el orden de llegada."""
99
+ merged: List[RuntimeErrorEvent] = []
100
+ for group in groups:
101
+ merged.extend(group or [])
102
+ return merged
@@ -0,0 +1,229 @@
1
+ """Firewall Anti-SSRF para contextos efimeros de Playwright.
2
+
3
+ El dominio de este modulo es puro: recibe un host y decide si es enrutable en
4
+ Internet o si pertenece a loopback / rangos privados / metadata de nube. El
5
+ adaptador de navegador instala el interceptor ``context.route("**/*")``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import logging
12
+ from typing import Any, Iterable, Optional
13
+ from urllib.parse import urlparse
14
+ import inspect
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BLOCKED_ABORT_REASON = "accessdenied"
19
+
20
+ #: Hostnames bloqueados de forma explicita (loopback, wildcards y metadata cloud).
21
+ BLOCKED_HOSTNAMES: frozenset[str] = frozenset(
22
+ {
23
+ "localhost",
24
+ "localhost.localdomain",
25
+ "ip6-localhost",
26
+ "ip6-loopback",
27
+ "metadata",
28
+ "metadata.google.internal",
29
+ "metadata.goog",
30
+ "instance-data",
31
+ "0.0.0.0",
32
+ "::",
33
+ "::1",
34
+ }
35
+ )
36
+ #: Hostnames de loopback/local permitidos en modo desarrollador local.
37
+ LOCAL_HOSTNAMES: frozenset[str] = frozenset(
38
+ {
39
+ "localhost",
40
+ "localhost.localdomain",
41
+ "ip6-localhost",
42
+ "ip6-loopback",
43
+ "0.0.0.0",
44
+ "::",
45
+ "::1",
46
+ }
47
+ )
48
+
49
+ #: Hostnames de metadata cloud: bloqueo incondicional (nunca es un sitio web).
50
+ METADATA_HOSTNAMES: frozenset[str] = frozenset(
51
+ {
52
+ "metadata",
53
+ "metadata.google.internal",
54
+ "metadata.goog",
55
+ "instance-data",
56
+ }
57
+ )
58
+
59
+ #: Redes privadas, de loopback, link-local y de metadata (RFC 1918/5735/6890).
60
+ BLOCKED_NETWORKS: tuple[Any, ...] = tuple(
61
+ ipaddress.ip_network(cidr)
62
+ for cidr in (
63
+ "0.0.0.0/8",
64
+ "10.0.0.0/8",
65
+ "100.64.0.0/10",
66
+ "127.0.0.0/8",
67
+ "169.254.0.0/16",
68
+ "172.16.0.0/12",
69
+ "192.0.0.0/24",
70
+ "192.168.0.0/16",
71
+ "198.18.0.0/15",
72
+ "::1/128",
73
+ "fc00::/7",
74
+ "fe80::/10",
75
+ )
76
+ )
77
+
78
+ #: Redes de infraestructura privada permitidas cuando VFA_ALLOW_LOCAL_HOST=true.
79
+ LOCAL_INFRA_NETWORKS: tuple[Any, ...] = tuple(
80
+ ipaddress.ip_network(cidr)
81
+ for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
82
+ )
83
+
84
+ #: Endpoints de metadata cloud: SIEMPRE bloqueados, incluso en modo local.
85
+ METADATA_NETWORKS: tuple[Any, ...] = (ipaddress.ip_network("169.254.0.0/16"),)
86
+
87
+ #: Esquemas sin host enrutable que jamas deben salir por la red.
88
+ BLOCKED_SCHEMES: frozenset[str] = frozenset({"file", "about", "data", "blob", "chrome"})
89
+
90
+
91
+ def _normalize_host(host: str) -> str:
92
+ """Normaliza un host: minusculas, sin corchetes IPv6 ni punto final."""
93
+ value = (host or "").strip().lower()
94
+ if value.startswith("[") and value.endswith("]"):
95
+ value = value[1:-1]
96
+ return value.rstrip(".")
97
+
98
+
99
+ def _as_address(value: str) -> Optional[Any]:
100
+ """Parsea ``value`` como direccion IP o devuelve None si es un dominio."""
101
+ try:
102
+ return ipaddress.ip_address(value)
103
+ except ValueError:
104
+ return None
105
+
106
+
107
+ def is_local_host(host: str) -> bool:
108
+ """Indica si el host es loopback, ``localhost`` o un dominio ``*.local``."""
109
+ value = _normalize_host(host)
110
+ if not value:
111
+ return False
112
+ if value in LOCAL_HOSTNAMES:
113
+ return True
114
+ if value.endswith(".localhost") or value.endswith(".local"):
115
+ return True
116
+ address = _as_address(value)
117
+ if address is None:
118
+ return False
119
+ return bool(address.is_loopback or address.is_unspecified)
120
+
121
+
122
+ def is_metadata_host(host: str) -> bool:
123
+ """Indica si el host apunta a metadata cloud (bloqueo incondicional)."""
124
+ value = _normalize_host(host)
125
+ if not value:
126
+ return False
127
+ if value in METADATA_HOSTNAMES:
128
+ return True
129
+ address = _as_address(value)
130
+ if address is None:
131
+ return False
132
+ return any(address in network for network in METADATA_NETWORKS)
133
+
134
+
135
+ def is_private_infra_host(host: str) -> bool:
136
+ """Indica si el host pertenece a una red privada RFC 1918 (intranet)."""
137
+ address = _as_address(_normalize_host(host))
138
+ if address is None:
139
+ return False
140
+ return any(address in network for network in LOCAL_INFRA_NETWORKS)
141
+
142
+
143
+ def is_blocked_host(host: str, allow_local: bool = False) -> bool:
144
+ """Indica si ``host`` (nombre o IP) apunta a la intranet o al propio host.
145
+
146
+ Con ``allow_local=True`` (modo desarrollador local) se permite loopback,
147
+ ``localhost``, dominios ``*.local`` y redes privadas RFC 1918, pero se
148
+ mantiene el bloqueo de los endpoints de metadata cloud.
149
+ """
150
+ value = _normalize_host(host)
151
+ if not value:
152
+ # Un host vacio no es enrutable: se trata como bloqueado.
153
+ return True
154
+ if is_metadata_host(value):
155
+ return True
156
+ if allow_local and (is_local_host(value) or is_private_infra_host(value)):
157
+ return False
158
+ if value in BLOCKED_HOSTNAMES:
159
+ return True
160
+ if value.endswith(".localhost") or value.endswith(".internal"):
161
+ return True
162
+ address = _as_address(value)
163
+ if address is None:
164
+ return False # Dominio publico: se permite el intento.
165
+ if any(address in network for network in BLOCKED_NETWORKS):
166
+ return True
167
+ return bool(
168
+ address.is_private
169
+ or address.is_loopback
170
+ or address.is_link_local
171
+ or address.is_reserved
172
+ or address.is_multicast
173
+ )
174
+
175
+
176
+ def is_blocked_url(url: str, allow_local: bool = False) -> bool:
177
+ """Indica si la URL es insegura (SSRF) y debe abortarse antes de salir."""
178
+ try:
179
+ parsed = urlparse(url or "")
180
+ except ValueError:
181
+ return True
182
+ scheme = (parsed.scheme or "").lower()
183
+ if scheme in BLOCKED_SCHEMES:
184
+ return True
185
+ if scheme not in ("http", "https", "ws", "wss"):
186
+ return True
187
+ return is_blocked_host(parsed.hostname or "", allow_local=allow_local)
188
+
189
+
190
+ def blocked_urls(urls: Iterable[str], allow_local: bool = False) -> list[str]:
191
+ """Utilidad de diagnostico: retorna el subconjunto de URLs bloqueadas."""
192
+ return [url for url in urls if is_blocked_url(url, allow_local=allow_local)]
193
+
194
+
195
+ def _guard_route(allow_local: bool = False) -> Any:
196
+ """Construye el handler de ``context.route`` con la politica Anti-SSRF dada."""
197
+
198
+ async def handler(route: Any, request: Any) -> None:
199
+ try:
200
+ url = str(getattr(request, "url", "") or "")
201
+ except Exception: # pragma: no cover - driver opcional
202
+ url = ""
203
+ if is_blocked_url(url, allow_local=allow_local):
204
+ logger.warning("SSRF bloqueado: %s", url or "<url desconocida>")
205
+ await route.abort(BLOCKED_ABORT_REASON)
206
+ return
207
+ await route.continue_()
208
+
209
+ return handler
210
+
211
+
212
+ async def attach_ssrf_guard(context: Any, allow_local: bool = False) -> Optional[Any]:
213
+ """Instala el interceptor Anti-SSRF en el contexto (si soporta routing)."""
214
+ registrar = getattr(context, "route", None)
215
+ if not callable(registrar):
216
+ return None
217
+ try:
218
+ res = registrar("**/*", _guard_route(allow_local=allow_local))
219
+ if inspect.isawaitable(res):
220
+ await res
221
+ return res
222
+ except Exception: # pragma: no cover - contexto ya cerrado
223
+ logger.debug("No se pudo instalar el firewall Anti-SSRF", exc_info=True)
224
+ return None
225
+
226
+
227
+ def route_handler(allow_local: bool = False) -> Any:
228
+ """Expone el handler para pruebas unitarias sin I/O real."""
229
+ return _guard_route(allow_local=allow_local)
@@ -0,0 +1,329 @@
1
+ """Adaptador Playwright: Chromium local sandboxed (motor unico, sin Docker).
2
+
3
+ Cada sesion se ejecuta en un ``BrowserContext`` efimero en memoria con firewall
4
+ Anti-SSRF instalado; no se persiste cache, cookies ni almacenamiento en disco.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from app import config
13
+ from app.adapters.browser.capture import ErrorCapture, attach_capture
14
+ from app.adapters.browser.network_guard import attach_ssrf_guard
15
+ from app.adapters.browser.visual_primitives import VisualPrimitivesMixin
16
+ from app.domain.models import RuntimeErrorEvent
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _VIEWPORT = {"width": 1280, "height": 720}
21
+
22
+ TIMEOUT_ACTIONS = config.get_action_timeout_ms()
23
+
24
+ #: Flags de aislamiento estricto aplicadas al arranque de Chromium.
25
+ SECURE_LAUNCH_FLAGS: Tuple[str, ...] = (
26
+ "--disable-background-networking",
27
+ "--disable-default-apps",
28
+ "--disable-sync",
29
+ "--no-first-run",
30
+ "--hide-scrollbars",
31
+ "--disable-component-update",
32
+ "--disable-domain-reliability",
33
+ "--no-default-browser-check",
34
+ "--disable-dev-shm-usage",
35
+ )
36
+
37
+ #: Opciones del contexto efimero: sin persistencia ni permisos implicitos.
38
+ EPHEMERAL_CONTEXT_OPTIONS: Dict[str, Any] = {
39
+ "viewport": dict(_VIEWPORT),
40
+ "ignore_https_errors": False,
41
+ "bypass_csp": False,
42
+ "accept_downloads": False,
43
+ "java_script_enabled": True,
44
+ }
45
+
46
+
47
+ class BrowserLaunchError(RuntimeError):
48
+ """Error irrecuperable al inicializar el navegador."""
49
+
50
+
51
+ def is_session_dead(browser: Any, page: Any) -> bool:
52
+ """Detecta si la pagina o el navegador se cerraron (defensivo ante fakes)."""
53
+ try:
54
+ is_closed = getattr(page, "is_closed", None)
55
+ if callable(is_closed) and is_closed():
56
+ return True
57
+ is_connected = getattr(browser, "is_connected", None)
58
+ if callable(is_connected) and not is_connected():
59
+ return True
60
+ except Exception: # pragma: no cover - defensivo
61
+ return True
62
+ return False
63
+
64
+
65
+ class PlaywrightSession(VisualPrimitivesMixin):
66
+ """Sesion activa que implementa `BrowserSessionPort` sobre Playwright."""
67
+
68
+ def __init__(
69
+ self,
70
+ browser: Any,
71
+ page: Any,
72
+ playwright: Any = None,
73
+ capture: Optional[ErrorCapture] = None,
74
+ mode: str = "local",
75
+ context: Any = None,
76
+ ) -> None:
77
+ self.browser = browser
78
+ self.page = page
79
+ self.playwright = playwright
80
+ self.mode = mode
81
+ self.context = context
82
+ self.capture = attach_capture(page, capture or ErrorCapture())
83
+
84
+ # ------------------------------------------------------------- navegacion
85
+ async def goto(self, url: str, timeout: int = 30000) -> None:
86
+ await self.page.goto(url, wait_until="domcontentloaded", timeout=timeout)
87
+
88
+ async def url(self) -> str:
89
+ value = getattr(self.page, "url", "")
90
+ return value() if callable(value) else str(value or "")
91
+
92
+ async def title(self) -> str:
93
+ getter = getattr(self.page, "title", None)
94
+ if callable(getter):
95
+ return str(await getter())
96
+ return ""
97
+
98
+ async def screenshot_b64(self, full_page: bool = True) -> str:
99
+ import base64
100
+
101
+ data = await self.page.screenshot(full_page=full_page)
102
+ if isinstance(data, str):
103
+ return data
104
+ return base64.b64encode(data).decode("utf-8")
105
+
106
+ async def accessibility_snapshot(self) -> Any:
107
+ snapshot = getattr(self.page, "accessibility", None)
108
+ if snapshot is not None and hasattr(snapshot, "snapshot"):
109
+ try:
110
+ return await snapshot.snapshot()
111
+ except Exception: # pragma: no cover - driver opcional
112
+ return None
113
+ try:
114
+ return await self.page.locator("body").aria_snapshot()
115
+ except Exception: # pragma: no cover - driver opcional
116
+ return None
117
+
118
+ # --------------------------------------------------------------- acciones
119
+ async def click(
120
+ self, selector: str, timeout: int = TIMEOUT_ACTIONS
121
+ ) -> Dict[str, Any]:
122
+ await self.page.click(selector, timeout=timeout)
123
+ return {"action": "click", "selector": selector, "ok": True}
124
+
125
+ async def fill(
126
+ self, selector: str, value: str, timeout: int = TIMEOUT_ACTIONS
127
+ ) -> Dict[str, Any]:
128
+ await self.page.fill(selector, value, timeout=timeout)
129
+ return {"action": "fill", "selector": selector, "value": value, "ok": True}
130
+
131
+ async def press(self, key: str) -> Dict[str, Any]:
132
+ await self.page.keyboard.press(key)
133
+ return {"action": "press", "value": key, "ok": True}
134
+
135
+ async def scroll(self, direction: str = "down") -> Dict[str, Any]:
136
+ delta = 900 if str(direction).lower() != "up" else -900
137
+ await self.page.mouse.wheel(0, delta)
138
+ return {"action": "scroll", "value": direction, "ok": True}
139
+
140
+ async def wait(self, seconds: float) -> Dict[str, Any]:
141
+ await self.page.wait_for_timeout(max(int(float(seconds) * 1000), 0))
142
+ return {"action": "wait", "value": seconds, "ok": True}
143
+
144
+ async def text_content(self) -> str:
145
+ try:
146
+ return str(await self.page.inner_text("body"))
147
+ except Exception: # pragma: no cover - pagina sin body
148
+ return ""
149
+
150
+ # --------------------------------------------------------------- cookies
151
+ async def cookies(self) -> List[Dict[str, Any]]:
152
+ for holder in (self.context, self.browser):
153
+ provider = getattr(holder, "cookies", None)
154
+ if callable(provider):
155
+ try:
156
+ return list(await provider())
157
+ except Exception: # pragma: no cover
158
+ return []
159
+ return []
160
+
161
+ async def restore(self, cookies: List[Dict[str, Any]], url: str = "") -> None:
162
+ for holder in (self.context, self.browser):
163
+ adder = getattr(holder, "add_cookies", None)
164
+ if callable(adder) and cookies:
165
+ try:
166
+ await adder(cookies)
167
+ except Exception: # pragma: no cover
168
+ logger.debug("No se pudieron restaurar cookies", exc_info=True)
169
+ break
170
+ if url:
171
+ await self.goto(url)
172
+
173
+ async def close(self) -> None:
174
+ """Destruye la pagina y el contexto efimero (nada persiste en disco)."""
175
+ closer = getattr(self.page, "close", None)
176
+ if callable(closer):
177
+ try:
178
+ await closer()
179
+ except Exception: # pragma: no cover - pagina ya cerrada
180
+ pass
181
+ context_closer = getattr(self.context, "close", None)
182
+ if callable(context_closer):
183
+ try:
184
+ await context_closer()
185
+ except Exception: # pragma: no cover - contexto ya cerrado
186
+ pass
187
+ self.context = None
188
+
189
+ def is_dead(self) -> bool:
190
+ return is_session_dead(self.browser, self.page)
191
+
192
+
193
+ class PlaywrightBrowserAdapter:
194
+ """Gestor del navegador: Chromium local sandboxed y CDP opcional generico."""
195
+
196
+ def __init__(self, playwright_manager: Any = None) -> None:
197
+ self._manager = playwright_manager
198
+ self._playwright: Any = None
199
+ self._browser: Any = None
200
+ self._capture = ErrorCapture()
201
+ self._orphan_contexts: List[Any] = []
202
+ self.mode = "local"
203
+
204
+ # ------------------------------------------------------------- internos
205
+ async def _ensure_playwright(self) -> Any:
206
+ if self._playwright is None:
207
+ if self._manager is None:
208
+ from playwright.async_api import async_playwright
209
+
210
+ self._manager = async_playwright()
211
+ self._playwright = await self._manager.start()
212
+ return self._playwright
213
+
214
+ def _launch_args(self) -> Dict[str, Any]:
215
+ """Argumentos de arranque: siempre con flags de aislamiento estricto."""
216
+ headless = config.get_headless()
217
+ args = {"headless": headless, "args": list(SECURE_LAUNCH_FLAGS)}
218
+ if not headless:
219
+ args["args"].append("--start-maximized")
220
+ return args
221
+
222
+ async def connect(self) -> Any:
223
+ """Arranca Chromium local o intenta un CDP generico configurado."""
224
+ playwright = await self._ensure_playwright()
225
+ remote_url = config.get_browser_cdp_url()
226
+ if remote_url:
227
+ timeout = config.get_cdp_timeout_ms()
228
+ try:
229
+ self._browser = await playwright.chromium.connect_over_cdp(
230
+ remote_url, timeout=timeout
231
+ )
232
+ self.mode = "cdp"
233
+ logger.info("Navegador conectado via CDP: %s", remote_url)
234
+ return self._browser
235
+ except Exception as exc:
236
+ logger.warning(
237
+ "Conexion CDP fallida (%s); fallback a Chromium local", exc
238
+ )
239
+ self._browser = await playwright.chromium.launch(**self._launch_args())
240
+ self.mode = "local"
241
+ return self._browser
242
+
243
+ async def _ensure_browser(self) -> None:
244
+ if self._browser is None or is_session_dead(self._browser, None):
245
+ await self.connect()
246
+
247
+ async def _new_context(self) -> Any:
248
+ """Crea un contexto efimero en memoria con firewall Anti-SSRF.
249
+
250
+ La politica Anti-SSRF respeta ``VFA_ALLOW_LOCAL_HOST``: en modo
251
+ desarrollador local se permite auditar ``localhost`` y ``*.local``.
252
+ """
253
+ await self._ensure_browser()
254
+ creator = getattr(self._browser, "new_context", None)
255
+ if not callable(creator):
256
+ return None
257
+ try:
258
+ context = await creator(**EPHEMERAL_CONTEXT_OPTIONS)
259
+ except TypeError: # pragma: no cover - implementaciones parciales
260
+ context = await creator()
261
+ await attach_ssrf_guard(context, allow_local=config.get_allow_local_host())
262
+ return context
263
+
264
+ async def _new_page(self) -> Any:
265
+ context = await self._new_context()
266
+ if context is not None:
267
+ self._orphan_contexts.append(context)
268
+ return await context.new_page()
269
+ await self._ensure_browser()
270
+ return await self._browser.new_page(viewport=dict(_VIEWPORT))
271
+
272
+ # --------------------------------------------------------------- publicos
273
+ async def new_page(self) -> Any:
274
+ """Pagina suelta en su propio contexto efimero (cerrado en `reset`)."""
275
+ return await self._new_page()
276
+
277
+ async def open_session(self) -> PlaywrightSession:
278
+ context = await self._new_context()
279
+ if context is not None:
280
+ page = await context.new_page()
281
+ else: # pragma: no cover - fallback defensivo
282
+ await self._ensure_browser()
283
+ page = await self._browser.new_page(viewport=dict(_VIEWPORT))
284
+ return PlaywrightSession(
285
+ browser=self._browser,
286
+ page=page,
287
+ playwright=self._playwright,
288
+ capture=self._capture,
289
+ mode=self.mode,
290
+ context=context,
291
+ )
292
+
293
+ async def close_session(self, session: Optional[PlaywrightSession] = None) -> None:
294
+ if session is not None:
295
+ await session.close()
296
+
297
+ def drain_errors(self) -> List[RuntimeErrorEvent]:
298
+ return self._capture.drain()
299
+
300
+ def peek_errors(self) -> List[RuntimeErrorEvent]:
301
+ return self._capture.events()
302
+
303
+ async def reset(self) -> None:
304
+ """Fuerza la reconexion en el siguiente `open_session`."""
305
+ for context in self._orphan_contexts:
306
+ closer = getattr(context, "close", None)
307
+ if callable(closer):
308
+ try:
309
+ await closer()
310
+ except Exception: # pragma: no cover - contexto ya cerrado
311
+ pass
312
+ self._orphan_contexts.clear()
313
+ if self._browser is not None:
314
+ try:
315
+ await self._browser.close()
316
+ except Exception: # pragma: no cover - navegador ya muerto
317
+ pass
318
+ self._browser = None
319
+
320
+ async def shutdown(self) -> None:
321
+ await self.reset()
322
+ if self._playwright is not None:
323
+ stopper = getattr(self._playwright, "stop", None)
324
+ if callable(stopper):
325
+ try:
326
+ await stopper()
327
+ except Exception: # pragma: no cover
328
+ pass
329
+ self._playwright = None