# lab-agent — agente de diagnóstico/corrección del lab Wrapper headless que corre `claude -p` para diagnosticar el estado del lab (kubectl, docker, curl a endpoints públicos, API de Gitea Actions) y, solo para un conjunto explícito y aprobado de recursos, ejecutar una acción correctiva de bajo riesgo. Ver la lista blanca completa, la justificación de gobernanza y la evidencia de la prueba end-to-end en `apps-registry` → `docs/arquitectura/agente-monitoreo.md`. **Estado (2026-08-26): script y prueba end-to-end validados en vivo** contra el cluster real (deployment canario desechable, ciclo completo DRY_RUN → ejecución real → rate limit/bloqueo → limpieza). El timer de systemd (Paso 4) **todavía no está activo** — pendiente de tu confirmación explícita. ## Diseño de seguridad (por qué Claude no ejecuta comandos directamente) `claude -p` se invoca con `--allowedTools ""` (cero herramientas) y `--json-schema`, así que el modelo **nunca** ejecuta un comando — solo devuelve una decisión estructurada (`{"actions":[{"resource", "decision", "reason"}], "summary"}`). Este script bash es el único que ejecuta `kubectl`/`docker`, y solo después de revalidar la decisión contra su propio cálculo de elegibilidad (rate limit, umbral de tiempo, ventana horaria, bloqueo tras intento previo). Se descartó dar a Claude un `Bash` tool con un patrón tipo `Bash(kubectl rollout restart deployment/medusa-deploy -n ecommerce)` porque se comprobó en vivo que `--allowedTools` hace *coincidencia por prefijo*, no coincidencia exacta: un patrón `Bash(echo pintest)` también permite ejecutar `echo pintest && echo INJECTED` o `echo pintest; rm -rf /`. Para un comando root-capaz eso no es un límite seguro. Con `--allowedTools ""`, Claude no tiene ninguna superficie de ejecución — el peor caso posible es que proponga una decisión incorrecta en el JSON, y el wrapper la rechaza si el recurso no está en su propia lista de elegibles (ver `RESOURCE_ELIGIBILITY` en el script). El prompt también advierte explícitamente contra prompt injection: los datos de diagnóstico (logs, estado de pods) podrían contener texto arbitrario si algo en el cluster está comprometido, e instruye a Claude a ignorar cualquier instrucción incrustada ahí. ## Qué hace cada corrida 1. Lee `kubectl -n ecommerce get deployment -o json` y calcula salud por deployment (readyReplicas vs replicas, condición `Available`). 2. Chequea `docker inspect gitea-runner` (estado del contenedor) y, si existe `GITEA_TOKEN_FILE`, la API de Gitea Actions (`/repos/{owner}/{repo}/actions/runs`) buscando runs en estado `running` atascados por más de `UNHEALTHY_THRESHOLD_MIN` minutos — la señal específica del incidente de runner zombie del 2026-08-16. Si el token no está o la API falla, esta señal se omite (fail-safe: nunca dispara una acción sobre una lectura no verificada) y solo queda la señal de "contenedor no está `running`". 3. Curl a los dominios públicos — puramente informativo, nunca gatilla una acción. 4. Calcula elegibilidad por recurso contra el estado persistido (`STATE_FILE`): umbral de 10 min, rate limit de 30 min entre acciones sobre el mismo recurso, ventana horaria 00:00–06:00 para `medusa-deploy` (el deployment real de "commerce-backend" en CLAUDE.md — confirmado en vivo 2026-08-25, no existe un deployment llamado literalmente `commerce-backend`), y bloqueo permanente tras un primer intento cuyo problema no se resolvió (sin reintentos silenciosos — requiere intervención humana para desbloquear, ver abajo). 5. Invoca `claude -p` con el diagnóstico + la lista de qué es elegible este ciclo. Cero herramientas, salida JSON validada por schema. 6. Para cada decisión `"restart"`: revalida que el recurso esté REALMENTE en la lista de elegibles del wrapper (no confía en el JSON de Claude por sí solo); si pasa, ejecuta el comando fijo correspondiente (`kubectl rollout restart deployment/ -n ecommerce` o `docker restart gitea-runner`), captura el resultado real (no lo que Claude cree que pasó) y marca el recurso como bloqueado para reintentos automáticos futuros. 7. Registra todo en `${LOG_DIR}/run-.log` (log completo de la corrida) y `${LOG_DIR}/actions.jsonl` (una línea JSON por decisión: timestamp, recurso, decisión, resultado, motivo — el registro auditable pedido en la lista blanca). 8. Notifica (Paso 3) si hubo alguna anomalía o acción — nunca en silencio si actuó. ## Desbloquear un recurso tras un intento fallido El estado vive en `STATE_FILE` (JSON). Para que un recurso vuelva a ser elegible sin esperar a que se recupere solo: ```bash jq 'del(.resources["deploy:medusa-deploy"])' "$STATE_FILE" > /tmp/s.json && mv /tmp/s.json "$STATE_FILE" ``` (cambiar la clave por `docker:gitea-runner` u otro `deploy:`). ## Configuración ```bash cp lab-agent.env.example lab-agent.env # editar umbrales/rutas si hace falta — lab-agent.env NO lleva secretos, # está en .gitignore a propósito (evita que una copia con paths de un # host distinto termine commiteada) ``` **Autenticación de `claude -p`:** `ANTHROPIC_API_KEY_FILE` queda vacío por defecto — el script usa el login de suscripción ya activo del usuario que lo corre (`~/.claude/.credentials.json`), no una API key aparte. Por eso el servicio systemd (cuando se active) debe correr como `devops`, no como `root`. Ver la sección "Autenticación" en `docs/arquitectura/agente-monitoreo.md` para el porqué completo. El único secreto real es `GITEA_TOKEN_FILE` (opcional, token de Gitea Actions con scope lectura) — dueño `devops`, sin sudo: ```bash mkdir -p "$(dirname "$GITEA_TOKEN_FILE")" # por defecto: /DATA/AppData/k3d-lab/lab-agent/secrets/ echo "" > "$GITEA_TOKEN_FILE" chmod 600 "$GITEA_TOKEN_FILE" ``` Si no existe, el agente sigue funcionando pero pierde la señal de "runner atascado" vía API de Gitea Actions (solo detecta el contenedor caído/reiniciando). ## Probar ```bash ./lab-agent.sh --dry-run # simulación, no toca el cluster ./lab-agent.sh --live # ejecución real ``` Validado en vivo 2026-08-26 con un deployment canario desechable (`test-agent-canary`, `busybox:stable` con `exit 1` — CrashLoopBackOff garantizado): DRY_RUN detecta y "ejecuta" sin tocar el cluster, `--live` ejecuta de verdad (confirmado con `kubectl get events` y la anotación `restartedAt`), y una segunda corrida sobre el mismo recurso caído respeta el bloqueo (`notify_only`, sin reintento). Dos bugs reales se encontraron y corrigieron en esa prueba — ver el detalle en `docs/arquitectura/agente-monitoreo.md`. **No actives el timer de systemd (Paso 4) todavía** — requiere tu confirmación explícita por separado, aunque la prueba end-to-end ya pasó.