feat(auditoría visual): añadir scripts y documentación para auditorías visuales autenticadas con Playwright MCP

This commit is contained in:
David Arranz 2026-10-21 17:08:29 +02:00
parent 27f428d114
commit 9b3363198c
5 changed files with 538 additions and 1 deletions

View File

@ -359,6 +359,8 @@ Al finalizar:
- El servidor utiliza Docker, stdio y la imagen oficial fijada por digest. No instalar Chromium en WSL, publicar puertos MCP ni crear una configuración global duplicada.
- Confirmar la URL anunciada por Vite y el puerto real antes de navegar. Con la red host configurada, utilizar el mismo origen local que Metis; no sustituirlo por el puerto de la API ni por `host.docker.internal` sin comprobar Vite y APP_ORIGIN.
- Para comprobaciones reproducibles usar una API y datos de prueba; toda prueba con persistencia utiliza exclusivamente `metis_desk_test`. No crear usuarios, guardar formularios ni modificar datos reales como parte de una auditoría visual sin autorización específica.
- Para inspecciones autenticadas, utilizar `python3 scripts/visual-audit.py`: prepara una instalación desechable en otro contenedor, accede por el formulario real con un usuario ordinario y proporciona una sesión privada para el lanzador de Codex. No ejecutar las fixtures destructivas de integración para preparar la auditoría ni conectar este procedimiento a una base existente. El administrador de preparación solo existe durante la vida del contenedor temporal.
- Mantener el proceso de auditoría abierto mientras Codex utiliza su sesión. Pasar `METIS_PLAYWRIGHT_STORAGE_STATE` únicamente al lanzador documentado; no copiar cookies, contraseñas ni `storageState` a mensajes, configuración global, código o Git. Al terminar, cerrar el proceso para retirar los servicios temporales y borrar la sesión. `--check` verifica el recorrido y limpia automáticamente.
- Comprobar teclado, foco, estados de carga/error/vacío, tamaños estrechos y amplios y contenido extenso según el cambio. Guardar evidencia local en `.artifacts/playwright/` y mantenerla fuera de Git; no guardar credenciales ni estados de sesión en el repositorio.
- Abrir e inspeccionar las capturas antes de afirmar una revisión visual. Una imagen PNG válida, un snapshot accesible y una prueba funcional son evidencias distintas; indicar qué se ha ejecutado realmente.
- Si Docker, la aplicación o el MCP no están disponibles, diagnosticar el fallo y comunicar los escenarios pendientes. No presentar como superadas comprobaciones que no se han ejecutado.

View File

@ -133,6 +133,80 @@ Localiza la raíz del clon, mantiene el nombre `playwright` y conserva el resto
Este mecanismo alternativo inicia el CLI; no registra automáticamente herramientas en la extensión o Desktop. Si sus políticas administradas impiden ejecutar Docker, resolver los permisos con el administrador: el lanzador no evita esas restricciones. El comprobador Python permite verificar stdio y la captura independientemente de la carga de herramientas del cliente, sin afirmar que con ello Desktop o la extensión estén validados.
## Auditorías visuales autenticadas
### Autenticación existente y decisión
No hay usuarios de desarrollo predeterminados. La instalación real se configura mediante `/api/auth/bootstrap`, que crea un administrador inicial protegido; `/api/users` permite a ese administrador crear usuarios ordinarios. No existe un catálogo general de roles: la gestión de usuarios requiere ser el administrador inicial, mientras el usuario ordinario puede acceder al espacio de soporte.
`/api/auth/login` comprueba la contraseña mediante scrypt y renueva la sesión. MariaDB guarda las sesiones; la cookie `metis.sid` es HttpOnly, SameSite=Strict y dura ocho horas. En producción también es Secure. No se guarda un token de acceso en localStorage. `/api/auth/session` proporciona el token CSRF y las operaciones de escritura exigen ese token y el origen configurado. `/api/auth/logout` destruye la sesión y elimina la cookie. El servidor comprueba usuario activo y versión de autenticación; la protección de datos no depende solo de las redirecciones del cliente. Véase [usuarios y acceso](usuarios-y-acceso.md).
El proyecto dispone de `metis_desk_test` y fixtures de integración, pero algunas pruebas limpian tablas o fabrican sesiones para probar contratos concretos. **No ejecutar esas pruebas para preparar una auditoría visual**: tampoco se utiliza la base de pruebas existente.
`scripts/visual-audit.py` prepara una instalación desechable mediante los endpoints públicos reales:
1. Crea un contenedor MariaDB independiente, con un volumen anónimo, nombre aleatorio, puerto libre publicado solo en `127.0.0.1`, `metis_desk_test` y contraseñas aleatorias.
2. Arranca la API existente con esa conexión, un secreto de sesión aleatorio y `NODE_ENV=test`. No carga `.env` ni acepta una conexión de base de datos suministrada por el usuario.
3. Ejecuta el bootstrap normal y crea un usuario ordinario, una compañía sintética y un portátil a través de HTTP con sesión y CSRF válidos. El administrador solo sirve para preparar esta instalación temporal: no se conserva en ninguna base existente.
4. Cierra la sesión de preparación. Arranca Vite en otro puerto libre, con el proxy y `APP_ORIGIN` alineados automáticamente.
5. Playwright MCP abre el formulario real, rellena las credenciales generadas y pulsa Entrar. Comprueba la identidad ordinaria y el rechazo 403 de `/api/users`.
6. Abre listado, ficha y edición del activo; captura las pantallas y cancela el diálogo sin guardar. Exporta la sesión obtenida y verifica su reutilización desde otro contenedor MCP.
No se añaden endpoints de acceso especial, cookies fabricadas, excepciones de autorización ni cambios en código de negocio. MariaDB utiliza el índice oficial fijado en el script, contrastado con el registro: `mariadb:11.8@sha256:6422478cb8e159f080fb1d8ccf65101e26fe51385787fde7d16c3b165a331f15`. Al actualizarlo, verificar el nuevo índice y repetir ambas modalidades. La imagen MCP sigue procediendo de `.codex/config.toml`.
### Preparación en otro WSL
Se necesitan los mismos requisitos de este documento, las dependencias del proyecto y Docker con acceso a Microsoft Container Registry y Docker Hub. Desde la raíz del clon:
```bash
pnpm install --frozen-lockfile
bash scripts/setup-playwright-wsl.sh
python3 scripts/visual-audit.py --check
```
Este procedimiento no necesita `pnpm db:up`, una copia de `.env` ni datos reales. `--check` ejecuta el recorrido, comprueba que logout devuelve 200 y que después el acceso protegido devuelve 401, y retira los servicios. Conserva capturas y un informe sin cookies; termina con código distinto de cero si falla. No valida por sí solo la exposición de las herramientas en un cliente Codex concreto.
### Mantener una sesión disponible para Codex
En una terminal WSL, desde el clon:
```bash
python3 scripts/visual-audit.py
```
Esperar el mensaje de verificación. El proceso imprime la URL exacta del inventario temporal y el comando para otra terminal, con su ruta real de sesión. Copiar ese comando, que tiene esta forma:
```bash
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
bash scripts/codex-playwright-wsl.sh
```
Sustituir `authenticated-ID` por el directorio que imprime el proceso. Las rutas relativas se resuelven contra la raíz del clon. Si se necesita el override de compatibilidad de la CLI antigua, añadir `-c 'service_tier="fast"'` al lanzador. No fijar rutas personales ni versiones duplicadas en la configuración global.
El lanzador mantiene la entrada `playwright` y añade solamente para ese proceso un montaje de solo lectura del archivo y `--storage-state`. Usa el UID/GID del usuario WSL para leer el archivo privado sin cambiar sus permisos. Comprueba que sea un archivo propio con permisos restringidos dentro de `.artifacts/playwright` y que contenga únicamente cookies de sesión locales de Metis. Sin la variable, conserva el comportamiento habitual sin sesión precargada. No modifica `.codex/config.toml` ni el archivo global.
Abrir una sesión nueva y comprobar `/mcp`. Navegar a **la URL temporal impresa**, no a la instancia habitual en 5173. Mantener abierto `visual-audit.py` durante el trabajo. El modo interactivo conserva la sesión; no ejecuta la prueba final de logout porque la invalidaría para Codex.
Cerrar primero la sesión de auditoría de Codex y después pulsar Ctrl+C en el proceso preparador. Se detienen sus grupos de procesos de API/Vite, se retira su contenedor y volumen anónimo y se elimina `session.json`. No se detiene MariaDB del proyecto ni se borran bases existentes. Cada ejecución genera un entorno nuevo; no reutiliza ni resetea datos de ejecuciones anteriores.
### Credenciales, sesiones y evidencia
- Las contraseñas del administrador y usuario son aleatorias y permanecen en memoria. El archivo temporal necesario para arrancar MariaDB se crea con modo 0600 y se elimina al arrancar el contenedor. No se imprimen credenciales, cookies, tokens ni resultados de rellenado del formulario.
- El directorio de ejecución tiene modo 0700 y `session.json`, modo 0600. La sesión es sensible aunque corresponda a datos sintéticos; no copiarla a mensajes ni guardarla en Git. Este tratamiento sigue el modelo de [estado autenticado de Playwright](https://playwright.dev/docs/auth).
- El directorio `.artifacts/playwright/` ya está excluido de Git. No guardar estados de navegador en otras ubicaciones. El archivo contiene el estado de una sesión real, no un mecanismo alternativo de autenticación; su validez depende del servidor y de la sesión persistida.
- Se guardan `00-login.png`, `01-authenticated.png`, `02-assets.png`, `03-detail.png`, `04-dialog.png`, `report.json` y los logs locales de API/Vite. Las capturas son del viewport, para conservar una composición legible en una aplicación con scroll interno. El informe contiene rutas, identificadores sintéticos, resultados y hashes de capturas, nunca cookies o contraseñas.
- Revisar las imágenes antes de afirmar una inspección visual. El recorrido solo comprueba acceso y navegación básica; no repite la auditoría UI/UX completa.
### Diagnóstico y límites
Si falla el arranque, revisar `api.log` y `vite.log` del directorio indicado. Los cuerpos de errores HTTP y los resultados de herramientas de login no se vuelcan, porque pueden repetir información de sesión. El plazo de disponibilidad está acotado y las esperas de interfaz usan los elementos reales de Playwright.
Tras terminar el preparador, su archivo de sesión deja de existir. Si se cierra sesión desde un cliente, también se invalida para otros contextos que compartan esa cookie; volver a preparar el entorno. No reutilizar estados caducados ni intentar recuperar credenciales desde los logs.
Una terminación forzada con SIGKILL o un fallo del daemon puede impedir el cleanup. Identificar los contenedores propios con `docker ps -a --filter name=metis-visual-` y sus volúmenes; retirar únicamente el nombre exacto de la ejecución afectada mediante `docker rm -f -v NOMBRE_DEL_CONTENEDOR_DE_AUDITORIA`. No usar limpiezas globales, `compose down -v` ni borrar el volumen de Metis. Las sesiones que queden en directorios de ejecuciones interrumpidas también deben retirarse.
La base es desechable, pero una auditoría sigue siendo de lectura salvo que la tarea autorice probar guardados. El conjunto inicial solo contiene una compañía y un activo: otras pantallas pueden necesitar fixtures adicionales explícitas. El usuario ordinario no permite auditar administración de usuarios. La sesión preparada se utiliza con el CLI WSL; no se ha comprobado su carga en extensión o Desktop. Tampoco se ha ejecutado esta modalidad en Docker Desktop o ARM. No hay navegador instalado en WSL ni cambios de seguridad de producción.
## Conectividad y origen
La URL del navegador y `APP_ORIGIN` deben coincidir exactamente en esquema, host y puerto. `localhost` y `127.0.0.1` son orígenes distintos aunque alcancen el mismo servicio. Si se usa el origen LAN del proyecto, comprobar ese origen y la configuración de la API; no desactivar sus protecciones.
@ -202,7 +276,7 @@ Comprobación ejecutada el 21 de octubre de 2026:
- Se retiraron los contenedores MCP temporales y se detuvieron la API y Vite arrancados para esta comprobación. Se conservó el contenedor de MariaDB existente y la imagen descargada en la caché Docker.
- Se comprobó la sintaxis Python y TOML, el rechazo de URLs con credenciales y la exclusión de artefactos de Git. No se ejecutaron typecheck, lint, tests ni build del negocio porque los cambios se limitan a configuración, documentación y el comprobador independiente.
La comprobación no cubre sesiones autenticadas, formularios de negocio, responsive ni accesibilidad completa. Docker Desktop y ejecución arm64 siguen pendientes de validación. La existencia de sus opciones o manifiestos no equivale a una prueba en esas plataformas.
La comprobación inicial no cubrió sesiones autenticadas ni formularios de negocio; su ampliación figura más abajo. Responsive y accesibilidad completa, Docker Desktop y ejecución arm64 siguen pendientes de validación. La existencia de sus opciones o manifiestos no equivale a una prueba en esas plataformas.
### Ampliación del procedimiento WSL
@ -211,3 +285,15 @@ En el mismo entorno se ejecutó `bash scripts/setup-playwright-wsl.sh` dos veces
Se contrastó el listado real del CLI 0.114.0 con y sin el lanzador alternativo: ambos muestran una sola entrada `playwright`. Se mantuvo el override puntual de `service_tier`, sin modificar configuración global. También se comprobó con un ejecutable de diagnóstico que el lanzador serializa overrides TOML válidos, funciona desde otro directorio y conserva stdin; no se atribuye esa comprobación a una sesión interactiva completa de Codex.
Se repitió la navegación real mediante `scripts/check-playwright-mcp.py` contra API de prueba y Vite: devolvió las 25 herramientas y la misma captura PNG de acceso, que se abrió e inspeccionó. Se registró de nuevo el 404 del favicon. La disponibilidad directa de las herramientas en esta sesión de agente, la extensión y Desktop permanece sin validar; el comprobador stdio y la carga de configuración del CLI son evidencias separadas.
### Verificación de acceso autenticado
Ejecutada el 21 de octubre de 2026, con Docker Engine local en WSL amd64:
- `python3 scripts/visual-audit.py --check`: login desde el formulario real, usuario ordinario, inventario, enlace a ficha, diálogo de edición y cancelación. Se generaron las cinco capturas y se comprobó la reutilización de la sesión desde otro contenedor. Se verificaron 401 sin autenticar, 403 en gestión de usuarios y logout 200 seguido de acceso protegido 401.
- `python3 scripts/visual-audit.py`: mismo recorrido en modalidad mantenida, con capturas de viewport de 1280×900 abiertas e inspeccionadas. Se verificaron permisos 0700 del directorio y 0600 de la sesión.
- El CLI 0.114.0 reconoció una sola entrada `playwright` con el montaje privado de solo lectura. Se tomó la configuración producida realmente por el lanzador y se inició con ella otro MCP: abrió el listado autenticado correctamente. Esto valida configuración y transporte; no afirma haber ejecutado una conversación interactiva nueva en Codex ni haber probado la extensión o Desktop.
- `python3 scripts/visual-audit.test.py`: seis pruebas correctas de comportamiento habitual, montaje privado, conservación de stdin, rechazo de permisos abiertos, cookies remotas, rutas externas y sesión inexistente. `bash -n`, sintaxis Python y `git diff --check`: correctos. No se ejecutaron pruebas ni compilaciones de negocio, cuyo código no se modifica.
- Al cerrar con SIGINT se detuvieron los servicios temporales, se retiró el contenedor desechable y se borraron los archivos de sesión y credenciales. Se comprobó que el contenedor MariaDB previo seguía disponible y que no quedaban contenedores `metis-visual-*`.
Las imágenes e informes están bajo el directorio local de ejecución y excluidos de Git. La prueba no guarda ediciones del activo ni repite una auditoría visual completa.

View File

@ -6,6 +6,7 @@ exec python3 -c "$(cat <<'PY'
import json
import os
import pathlib
import stat
import shutil
import sys
import tomllib
@ -16,6 +17,35 @@ if executable is None:
sys.exit('Error: instala Codex CLI en WSL o indica METIS_CODEX_BIN.')
with (root / '.codex/config.toml').open('rb') as source:
config = tomllib.load(source)['mcp_servers']['playwright']
state_path = os.environ.get('METIS_PLAYWRIGHT_STORAGE_STATE')
if state_path:
state = (root / state_path).resolve()
try:
metadata = state.stat()
except OSError:
sys.exit('Error: sesión ausente o inválida; vuelve a iniciar scripts/visual-audit.py.')
if (not state.is_relative_to(root / '.artifacts/playwright') or not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) & 0o077):
sys.exit('Error: la sesión debe ser un archivo propio y privado dentro de .artifacts/playwright.')
try:
data = json.loads(state.read_text())
except (OSError, ValueError):
sys.exit('Error: sesión inválida; vuelve a iniciar scripts/visual-audit.py.')
if not isinstance(data, dict) or not isinstance(data.get('cookies'), list):
sys.exit('Error: formato de sesión Playwright inválido.')
cookies = data['cookies']
if not cookies or any(not isinstance(cookie, dict) or cookie.get('domain') not in ('localhost', '127.0.0.1')
or cookie.get('name') != 'metis.sid' or cookie.get('httpOnly') is not True
for cookie in cookies):
sys.exit('Error: solo se admiten sesiones locales de auditoría de Metis.')
args = list(config['args'])
image_position = next(index for index, arg in enumerate(args) if arg.startswith('mcr.microsoft.com/playwright/mcp:'))
args[image_position:image_position] = [
'--user=' + str(os.getuid()) + ':' + str(os.getgid()),
'--mount=type=bind,source=' + str(state) + ',target=/tmp/metis-audit-state.json,readonly',
]
args.append('--storage-state=/tmp/metis-audit-state.json')
config['args'] = args
overrides = []
for key in ('command', 'args', 'startup_timeout_sec', 'tool_timeout_sec'):
overrides.extend(['-c', 'mcp_servers.playwright.' + key + '=' + json.dumps(config[key])])

332
scripts/visual-audit.py Normal file
View File

@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Prepara datos desechables y verifica un acceso real mediante Playwright MCP."""
import argparse
import base64
import contextlib
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import re
import runpy
import secrets
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import tomllib
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ROOT / ".artifacts/playwright"
# Índice oficial contrastado con el registro; no utiliza el volumen de compose del proyecto.
DATABASE_IMAGE = "mariadb:11.8@sha256:6422478cb8e159f080fb1d8ccf65101e26fe51385787fde7d16c3b165a331f15"
def private_write(path, text):
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(text)
def command(args, **options):
return subprocess.run(args, check=True, timeout=120, capture_output=True, text=True, **options)
def available_port():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return listener.getsockname()[1]
@contextlib.contextmanager
def child_process(args, environment, log):
with log.open("w", encoding="utf-8") as stream:
process = subprocess.Popen(args, cwd=ROOT, env=environment, stdout=stream,
stderr=stream, start_new_session=True)
try:
yield process
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=10)
def wait_for(check, process=None):
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
if process is not None and process.poll() is not None:
raise RuntimeError("Un servidor temporal terminó antes de estar disponible; revisa sus logs locales.")
if check():
return
# Reintento condicionado a disponibilidad, con plazo; no sustituye una espera de UI.
time.sleep(0.25)
raise RuntimeError("Tiempo agotado esperando un servicio temporal.")
class TestApi:
"""Cliente del HTTP público; nunca fabrica cookies, sesiones ni tokens CSRF."""
def __init__(self, base, origin):
self.base = base
self.origin = origin
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def request(self, path, body=None, expected=200):
headers = {}
if body is not None:
token = self.request("/auth/session")["csrfToken"]
headers = {"content-type": "application/json", "origin": self.origin, "x-csrf-token": token}
request = urllib.request.Request(self.base + "/api" + path,
data=None if body is None else json.dumps(body).encode(),
headers=headers)
try:
with self.opener.open(request, timeout=20) as response:
if response.status != expected:
raise RuntimeError("Respuesta HTTP inesperada en " + path)
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == expected:
return None
# Los cuerpos pueden contener datos de sesión; no se vuelcan en el diagnóstico.
raise RuntimeError(f"HTTP {error.code} en {path}") from None
def ready(self):
try:
self.request("/auth/session")
return True
except (OSError, RuntimeError):
return False
def prepare_fixture(api):
if not api.request("/auth/session")["setupRequired"]:
raise RuntimeError("La instancia desechable no está vacía; se rechaza preparar datos.")
password = secrets.token_urlsafe(32)
api.request("/auth/bootstrap", {"name": "Preparación temporal", "email": "setup@audit.example.test",
"password": secrets.token_urlsafe(32)}, expected=201)
user = api.request("/users", {"name": "Auditoría visual", "email": "visual@audit.example.test",
"password": password}, expected=201)["user"]
if user["isInitialAdmin"]:
raise RuntimeError("La cuenta de auditoría debe ser ordinaria.")
company = api.request("/companies", {"name": "Auditoría visual — Datos sintéticos", "active": True}, expected=201)["company"]
company_id = str(uuid.UUID(company["id"]))
catalog = api.request("/asset-catalog/" + company_id)["items"]
template = next((item for item in catalog if item["values"]["kind"] == "template"
and item["values"]["name"] == "Portátil"), None)
if template is None:
raise RuntimeError("El catálogo de serie no incluye la plantilla Portátil.")
asset = api.request("/assets/" + company_id, {"values": {
"name": "AUDIT-PORT-01", "templateId": template["id"], "siteId": None,
"registeredOn": "2026-01-15", "status": "operational", "notes": "Activo sintético para auditorías visuales.",
"characteristics": {},
}}, expected=201)["asset"]
api.request("/auth/logout", {})
return {"company": company_id, "asset": str(uuid.UUID(asset["id"])), "user": user["id"],
"email": "visual@audit.example.test", "password": password}
def tool(client, name, arguments):
try:
return client.tool(name, arguments)
except (RuntimeError, OSError) as error:
# Las respuestas de rellenado pueden repetir credenciales. No imprimirlas ni guardarlas.
if name == "browser_run_code_unsafe":
# Solo categorías de error conocidas; nunca contenido, argumentos ni snapshots.
category = "timeout" if "Timeout" in str(error) else "ejecución de código"
raise RuntimeError("Falló " + name + " (" + category + ").") from None
raise RuntimeError("Falló " + name + "; no se registra su contenido para proteger la sesión.") from None
def result_json(result):
for part in result.get("content", []):
if part.get("type") == "text" and "### Result\n" in part["text"]:
return json.JSONDecoder().raw_decode(part["text"].split("### Result\n", 1)[1].lstrip())[0]
raise RuntimeError("El MCP no devolvió el resultado JSON esperado.")
@contextlib.contextmanager
def browser(config, state=None):
name = "metis-visual-browser-" + uuid.uuid4().hex[:12]
args = ["docker", "run", "--name=" + name, *config["args"][1:]]
if state is not None:
image_position = next(index for index, arg in enumerate(args) if arg.startswith("mcr.microsoft.com/playwright/mcp:"))
args[image_position:image_position] = ["--user=" + str(os.getuid()) + ":" + str(os.getgid()),
"--mount=type=bind,source=" + str(state) + ",target=/tmp/metis-audit-state.json,readonly"]
args.append("--storage-state=/tmp/metis-audit-state.json")
# Reutiliza el cliente de diagnóstico existente, sin añadir un SDK ni duplicar JSON-RPC.
client_type = runpy.run_path(str(ROOT / "scripts/check-playwright-mcp.py"))["StdioClient"]
client = client_type(args, 120)
try:
client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "metis-authenticated-audit", "version": "1"}})
client.send({"method": "notifications/initialized"})
yield client
finally:
try:
client.close()
finally:
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=15, check=False)
def capture(client, directory, name):
result = tool(client, "browser_take_screenshot", {"type": "png", "fullPage": False, "scale": "css"})
part = next(item for item in result["content"] if item.get("type") == "image" and item.get("mimeType") == "image/png")
data = base64.b64decode(part["data"], validate=True)
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
raise RuntimeError("Captura PNG inválida.")
destination = directory / (name + ".png")
destination.write_bytes(data)
return {"file": destination.name, "sha256": hashlib.sha256(data).hexdigest()}
def verify_browser(config, origin, fixture, directory, state, logout):
code_tool = "browser_run_code_unsafe"
evidence = []
assets_path = "/companies/" + fixture["company"] + "/assets"
with browser(config) as client:
print("Verificando el formulario de acceso real…", flush=True)
tool(client, "browser_resize", {"width": 1280, "height": 900})
tool(client, "browser_navigate", {"url": origin + "/login"})
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('button', {name:'Entrar', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "00-login"))
tool(client, "browser_fill_form", {"fields": [
{"name": "Correo electrónico", "type": "textbox", "target": "input[type=email]", "value": fixture["email"]},
{"name": "Contraseña", "type": "textbox", "target": "input[type=password]", "value": fixture["password"]},
]})
tool(client, "browser_click", {"target": "button[type=submit]"})
tool(client, code_tool, {"code": "async (page) => { await page.locator('[data-slot=workspace]').waitFor({timeout:30000}); }"})
identity = result_json(tool(client, "browser_evaluate", {"function": """async () => {
const response = await fetch('/api/auth/session');
const {user} = await response.json();
return {id:user?.id, admin:user?.isInitialAdmin, usersStatus:(await fetch('/api/users')).status};
}"""}))
if identity != {"id": fixture["user"], "admin": False, "usersStatus": 403}:
raise RuntimeError("La sesión no corresponde al usuario ordinario esperado.")
evidence.append(capture(client, directory, "01-authenticated"))
print("Usuario ordinario autenticado; verificando listado, ficha y diálogo…", flush=True)
tool(client, "browser_navigate", {"url": origin + assets_path})
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "02-assets"))
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).click(); await page.getByRole('heading', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "03-detail"))
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('button', {name:'Editar activo', exact:true}).click(); await page.getByRole('dialog', {name:'Editar activo', exact:true}).waitFor(); }"})
evidence.append(capture(client, directory, "04-dialog"))
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('dialog').getByRole('button', {name:'Cancelar', exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); }"})
storage = result_json(tool(client, code_tool, {"code": "async (page) => await page.context().storageState()"}))
private_write(state, json.dumps(storage))
# Otro contenedor comprueba la reutilización por el mismo mecanismo que recibirá Codex.
with browser(config, state) as client:
tool(client, "browser_navigate", {"url": origin + assets_path})
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
if logout:
result = result_json(tool(client, "browser_evaluate", {"function": """async () => {
const {csrfToken} = await (await fetch('/api/auth/session')).json();
const response = await fetch('/api/auth/logout', {
method:'POST', body:'{}', headers:{'content-type':'application/json', 'x-csrf-token':csrfToken}
});
return {logout:response.status, protected:(await fetch('/api/companies')).status};
}"""}))
if result != {"logout": 200, "protected": 401}:
raise RuntimeError("El cierre de sesión no revocó el acceso esperado.")
return evidence
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="Verifica y retira el entorno; sin esta opción espera hasta Ctrl+C")
options = parser.parse_args()
for executable in ("docker", "node", "pnpm"):
if shutil.which(executable) is None:
parser.error("Falta " + executable + "; consulta docs/playwright-mcp.md")
if not command(["node", "--version"]).stdout.startswith("v24."):
parser.error("Se necesita Node.js 24.x.")
with (ROOT / ".codex/config.toml").open("rb") as source:
config = tomllib.load(source)["mcp_servers"]["playwright"]
images = [arg for arg in config["args"] if re.fullmatch(r"mcr\.microsoft\.com/playwright/mcp:v[\d.]+@sha256:[a-f0-9]{64}", arg)]
if config["command"] != "docker" or len(images) != 1 or "--network=host" not in config["args"]:
parser.error("Se requiere la configuración Docker fijada con red host.")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
directory = Path(tempfile.mkdtemp(prefix="authenticated-", dir=ARTIFACTS))
container = "metis-visual-db-" + uuid.uuid4().hex[:12]
container_created = False
database_password = secrets.token_urlsafe(32)
api_port, web_port = available_port(), available_port()
while api_port == web_port:
web_port = available_port()
origin = "http://localhost:" + str(web_port)
api = TestApi("http://127.0.0.1:" + str(api_port), origin)
state = directory / "session.json"
env_file = directory / "database.env"
try:
private_write(env_file, "MARIADB_DATABASE=metis_desk_test\nMARIADB_USER=visual_audit\nMARIADB_PASSWORD=" + database_password + "\nMARIADB_ROOT_PASSWORD=" + secrets.token_urlsafe(32) + "\n")
print("Preparando MariaDB desechable y servicios en puertos libres…", flush=True)
command(["docker", "run", "--detach", "--rm", "--name=" + container,
"--label=metis.visual-audit=true", "--publish=127.0.0.1::3306",
"--env-file=" + str(env_file), DATABASE_IMAGE])
container_created = True
env_file.unlink()
wait_for(lambda: subprocess.run(["docker", "exec", container, "healthcheck.sh", "--connect", "--innodb_initialized"],
capture_output=True, timeout=15, check=False).returncode == 0)
bindings = json.loads(command(["docker", "inspect", "--format={{json .NetworkSettings.Ports}}", container]).stdout)
database_port = bindings["3306/tcp"][0]["HostPort"]
# No carga .env: las únicas credenciales apuntan al contenedor recién creado.
environment = {**os.environ, "DB_HOST": "127.0.0.1", "DB_PORT": database_port,
"DB_NAME": "metis_desk_test", "DB_USER": "visual_audit", "DB_PASSWORD": database_password,
"SESSION_SECRET": secrets.token_urlsafe(48), "NODE_ENV": "test", "APP_ORIGIN": origin,
"PORT": str(api_port), "API_PROXY_TARGET": api.base}
with child_process(["node", "--import", "tsx", "apps/server/src/index.ts"], environment, directory / "api.log") as backend:
wait_for(api.ready, backend)
fixture = prepare_fixture(api)
TestApi(api.base, origin).request("/companies", expected=401)
with child_process(["pnpm", "--config.verify-deps-before-run=false", "--filter", "@metis/web", "exec", "vite",
"--host", "127.0.0.1", "--port", str(web_port), "--strictPort"], environment, directory / "vite.log") as frontend:
web = TestApi(origin, origin)
wait_for(web.ready, frontend)
evidence = verify_browser(config, origin, fixture, directory, state, options.check)
report = {"origin": origin, "companyId": fixture["company"], "assetId": fixture["asset"],
"ordinaryUser": True, "unauthenticatedStatus": 401, "userManagementStatus": 403,
"reusedSession": True, "logoutChecked": options.check,
"databaseImage": DATABASE_IMAGE, "mcpImage": images[0], "screenshots": evidence}
(directory / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print("Verificado: login real, usuario ordinario, listado, ficha, diálogo cancelado y sesión en otro contenedor.", flush=True)
print("Evidencia local: " + str(directory.relative_to(ROOT)), flush=True)
if not options.check:
print("Mantén este proceso abierto. En otra terminal WSL:", flush=True)
print("METIS_PLAYWRIGHT_STORAGE_STATE=" + str(state.relative_to(ROOT)) + " bash scripts/codex-playwright-wsl.sh", flush=True)
print("URL de auditoría: " + origin + "/companies/" + fixture["company"] + "/assets", flush=True)
print("Ctrl+C retira exclusivamente este entorno y su sesión.", flush=True)
signal.pause()
finally:
env_file.unlink(missing_ok=True)
state.unlink(missing_ok=True)
if container_created:
removed = subprocess.run(["docker", "rm", "-f", "-v", container], capture_output=True, timeout=30, check=False)
if removed.returncode != 0 and "No such container" not in removed.stderr.decode(errors="replace"):
raise RuntimeError("No se pudo confirmar la retirada del contenedor temporal; consulta la limpieza manual documentada.")
print("Entorno temporal retirado; las capturas se conservan sin sesión ni credenciales.", flush=True)
if __name__ == "__main__":
signal.signal(signal.SIGTERM, lambda *_: sys.exit(130))
try:
main()
except KeyboardInterrupt:
print("Auditoría finalizada.")
except (OSError, RuntimeError, ValueError, KeyError, StopIteration, subprocess.SubprocessError) as error:
# No volcar excepciones de HTTP/MCP: podrían incluir contraseñas o cookies.
print("La comprobación falló. Revisa los requisitos Docker/WSL y los logs de servicios locales; no se ha validado la integración.", file=sys.stderr)
if isinstance(error, RuntimeError):
print(str(error), file=sys.stderr)
sys.exit(1)

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Regresión del aislamiento de las sesiones del lanzador, sin Docker ni credenciales."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import tomllib
import unittest
ROOT = Path(__file__).resolve().parent.parent
class SessionLauncherTests(unittest.TestCase):
def setUp(self):
artifacts = ROOT / ".artifacts/playwright"
artifacts.mkdir(parents=True, exist_ok=True)
self.directory = tempfile.TemporaryDirectory(prefix="session-guard-", dir=artifacts)
self.root = Path(self.directory.name)
self.executable = self.root / "codex-probe"
self.executable.write_text("#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps({'args':sys.argv[1:],'stdin':sys.stdin.read()}))\n")
self.executable.chmod(0o700)
def tearDown(self):
self.directory.cleanup()
def state(self, domain="localhost", mode=0o600):
path = self.root / "session.json"
path.write_text(json.dumps({"cookies": [{"name": "metis.sid", "domain": domain,
"httpOnly": True, "value": "placeholder-not-a-session"}]}))
path.chmod(mode)
return path
def launch(self, state=None):
environment = {**os.environ, "METIS_CODEX_BIN": str(self.executable)}
environment.pop("METIS_PLAYWRIGHT_STORAGE_STATE", None)
if state is not None:
environment["METIS_PLAYWRIGHT_STORAGE_STATE"] = str(state)
return subprocess.run(["bash", str(ROOT / "scripts/codex-playwright-wsl.sh"), "mcp", "list", "--json"],
input="stdin-check", text=True, capture_output=True, env=environment,
cwd=self.root, timeout=15, check=False)
def args(self, result):
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertEqual(payload["stdin"], "stdin-check")
argument = next(item for item in payload["args"] if item.startswith("mcp_servers.playwright.args="))
return tomllib.loads(argument)["mcp_servers"]["playwright"]["args"]
def test_default_has_no_session(self):
self.assertFalse(any("storage-state" in item or "type=bind" in item for item in self.args(self.launch())))
def test_private_session_is_readonly_and_not_in_arguments(self):
state = self.state()
args = self.args(self.launch(state.relative_to(ROOT)))
self.assertTrue(any("readonly" in item and str(state) in item for item in args))
self.assertEqual(args.count("--storage-state=/tmp/metis-audit-state.json"), 1)
self.assertNotIn("placeholder-not-a-session", " ".join(args))
def test_public_permissions_are_rejected(self):
result = self.launch(self.state(mode=0o644))
self.assertNotEqual(result.returncode, 0)
self.assertIn("privado", result.stderr)
def test_remote_cookie_is_rejected(self):
result = self.launch(self.state(domain="production.example.test"))
self.assertNotEqual(result.returncode, 0)
self.assertIn("locales", result.stderr)
def test_external_file_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "session.json"
path.write_text("{}")
path.chmod(0o600)
result = self.launch(path)
self.assertNotEqual(result.returncode, 0)
self.assertIn("privado", result.stderr)
def test_missing_session_is_rejected(self):
result = self.launch(self.root / "missing.json")
self.assertNotEqual(result.returncode, 0)
self.assertIn("ausente", result.stderr)
if __name__ == "__main__":
unittest.main()