Files
apps-registry/workloads/docs-portal/docs/arquitectura/sesion-tmux-persistente.md
devopsandClaude Sonnet 5 8835d8ca0c
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m57s
docs(tmux-host): documentar Dockerfile + healthcheck cada 30 min
Actualiza sesion-tmux-persistente.md: la imagen de tmux-host ahora se
construye desde scripts/tmux-host/Dockerfile (WORKDIR fijo en la raiz
del lab) en vez del docker run inline anterior, y se agrega la seccion
del chequeo de salud en background (red + sesion tmux, cada 30 min,
notificacion via el mismo canal de Telegram del lab-agent). Actualiza
tambien la tabla "Que sobrevive a que" para reflejar la auto-recuperacion
de la sesion tmux.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012xyPak6HWFmBCqXrvFsejL
2026-09-06 17:18:10 +00:00

14 KiB

Sesión persistente de Claude Code (tmux en Docker)

Problema que resuelve

Correr Claude Code directo en una shell SSH sobre el NAS lo ata al ciclo de vida de esa conexión: un corte de red, cambiar de laptop a tablet, o cerrar la terminal por accidente mata el proceso y con él todo el contexto de trabajo en curso — no hay forma de "retomar donde quedó" porque el proceso ya no existe.

Esto es un problema distinto al de los límites de uso/cuota de Claude Code. Este setup no aumenta cuota de mensajes ni tokens — eso lo controla el backend de Anthropic sin importar dónde corra el CLI. Lo único que resuelve es que la sesión de terminal sobreviva a cortes de red o cambios de dispositivo; si la cuota se agota, sigue agotada exactamente igual dentro de este setup.

Por qué Docker y no instalación nativa

ZimaOS no tiene gestor de paquetes tradicional utilizable para esto: / está montado como squashfs de solo lectura (imagen del SO inmutable), y /usr es un overlay de solo lectura sobre eso — /usr/local/bin ni siquiera existe y no se puede crear, tampoco como root (mkdir falla con "Read-only file system"). No hay dónde instalar Node.js, npm ni tmux directamente sobre el host.

Un contenedor Docker sortea esto por completo: tiene su propio filesystem de escritura (overlay2), independiente del squashfs del host — ahí es donde npm install -g deja el binario de claude sin tocar el host en absoluto. Lo único que el contenedor toma del host es lo que se monta explícitamente como volumen.

Arquitectura

flowchart LR
    SSH["Cliente SSH<br/>(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host<br/>--network host, --restart unless-stopped"]
    C --> T["tmux server<br/>sesión 'lab'"]
    T --> CC["proceso claude<br/>(Claude Code)"]
    C --> HC["healthcheck.sh (background)<br/>cada 30 min"]
    HC -.->|curl| NET["gitea.cruzcloud.net"]
    HC -.->|recrea si falta| T
    C -.->|bind mount| DATA["/DATA (host)<br/>repos y configs del lab"]
    C -.->|bind mount| SOCK["/var/run/docker.sock (host)<br/>control de Docker/k3d"]
    C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)<br/>-> /root/.claude"]

El contenedor sigue vivo aunque la conexión SSH se corte — es el servidor tmux, no la shell del cliente, el que sostiene el proceso de Claude Code. --restart unless-stopped además lo revive si el NAS reinicia.

Contenedor: tmux-host

La imagen se construye desde scripts/tmux-host/Dockerfile (antes era un docker run ... alpine sh -c "apk add ..." inline — mismo contenido, pero ahora como imagen versionada en vez de reinstalar todo en cada arranque). El Dockerfile fija WORKDIR /DATA/AppData/k3d-lab-v3/lab-cruzcloud (la raíz del lab, con CLAUDE.md, apps-registry/, platform-infra/ y scripts/) y agrega healthcheck.sh (ver más abajo) como proceso en background del contenedor.

docker build -t tmux-host:local scripts/tmux-host

docker run -d \
  --name tmux-host \
  --restart unless-stopped \
  -v /DATA:/DATA \
  -v /media/HDD-Storage:/media/HDD-Storage \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
  -v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
  --network host \
  tmux-host:local

WORKDIR hace que docker exec (y por lo tanto lab-session.sh) arranquen ya posicionados en la raíz del lab sin tener que navegar a mano — lab-session.sh además pasa la ruta explícita vía tmux new -c por las dudas, para no depender de que docker exec herede el WORKDIR de la imagen.

Volumen Destino Por qué
/DATA /DATA Acceso a los repos (apps-registry, platform-infra, scripts) y configs del lab, mismos paths que en el host
/media/HDD-Storage /media/HDD-Storage Ver "Gotcha: el symlink /DATA/AppData" abajo — sin esto, los repos no se ven dentro del contenedor pese a montar /DATA
/var/run/docker.sock /var/run/docker.sock Seguir controlando Docker y k3d (contenedores, k3d node, etc.) desde dentro del contenedor
/DATA/AppData/k3d-lab-v3/claude-code-home /root/.claude Persistir login/config de Claude Code — sobrevive no solo a un docker restart, sino a recrear el contenedor entero
/DATA/AppData/k3d-lab-v3/claude-code-home.json /root/.claude.json Ver "Gotcha: la confianza del folder no persistía" abajo — es un archivo suelto, no vive dentro de ~/.claude/

Montar -v /DATA:/DATA no alcanza para ver los repos dentro del contenedor. /DATA/AppData en el host es un symlink absoluto:

/DATA/AppData -> /media/HDD-Storage/AppData

Los repos del lab (lab-cruzcloud) viven físicamente en el disco btrfs /media/HDD-Storage, no en el ext4 de /DATA — ZimaOS lo expone dentro de /DATA como submount (/DATA/.media/HDD-Storage/..., que sí viaja al contenedor porque Docker hace bind recursivo por defecto) pero el path "bonito" /DATA/AppData/... que usan CLAUDE.md y todos los scripts del lab llega ahí vía ese symlink absoluto, no vía el submount. Un symlink absoluto que apunta fuera del árbol montado queda roto dentro del contenedor aunque el submount subyacente esté perfectamente accesible por otra ruta.

Cómo se detectó: todo el trabajo de configuración inicial de este mismo setup se hizo con git/docker exec corriendo en el host, no dentro del contenedor — nadie había probado cd /DATA/AppData/k3d-lab-v3/lab-cruzcloud desde una sesión lab real hasta que se preguntó explícitamente por esto. ls devolvía "No such file or directory" pese a que el volumen de /DATA estaba montado y "andando".

Fix: montar también -v /media/HDD-Storage:/media/HDD-Storage, para que el symlink resuelva exactamente igual que en el host.

Gotcha: git "dubious ownership" dentro del contenedor

Con el mount anterior ya arreglado, git status/git log dentro del contenedor fallan con:

fatal: detected dubious ownership in repository at '/media/HDD-Storage/...'

Los archivos en /DATA//media/HDD-Storage pertenecen al usuario devops del host (via samba), pero los procesos dentro del contenedor corren como root (UID 0) — git, desde la versión que introdujo la protección CVE-2022-24765, rechaza operar sobre un repo cuyo dueño no coincide con el UID del proceso, a menos que el path esté explícitamente marcado como seguro.

Fix: git config --global --add safe.directory '*' dentro del contenedor. El wildcard (en vez de listar cada repo) es intencional acá: este contenedor ya tiene acceso root-equivalente al host vía el volumen de /var/run/docker.sock, así que restringir safe.directory por repo no suma aislamiento real.

Por qué está en el comando de arranque y no como paso manual aparte: igual que los paquetes apk, esta configuración vive en el filesystem efímero del contenedor (/root/.gitconfig, que no está bajo el volumen de /root/.claude) — se perdería si el contenedor se recrea. Ponerla en el sh -c de arranque la deja recreándose sola cada vez, sin pasos manuales que recordar.

Gotcha: la confianza del folder no persistía

Al abrir claude por primera vez en un directorio, pide confirmar un diálogo de "¿confiás en este folder?" (más aún acá, porque este repo trae un .claude/settings.local.json con permisos pre-aprobados). Esa confirmación se guarda en ~/.claude.json — un archivo suelto en /root, no dentro de la carpeta ~/.claude/ que ya estaba montada como volumen. Resultado: aunque el login (~/.claude/.credentials.json) sí persistía, el diálogo de confianza volvía a aparecer cada vez que se recreaba el contenedor — justo el tipo de "reconfigurar de nuevo" que este setup existe para evitar.

Cómo se detectó: al confirmar el diálogo por primera vez, se comparó /root/.claude.json dentro del contenedor contra sí mismo antes y después — mismo tamaño y mismo mtime, es decir, la confirmación en pantalla nunca se había registrado. La causa real: la primera vez se movió el selector con las flechas pero nunca se presionó Enter, así que en rigor el diálogo seguía sin confirmar; una vez confirmado de verdad (tmux send-keys moviendo el cursor a "Yes, I trust this folder" + Enter), el archivo saltó de 2.2 KB a 40 KB y apareció la clave projects con la ruta del repo adentro.

Fix: copiar ese .claude.json ya confirmado a /DATA/AppData/k3d-lab-v3/claude-code-home.json en el host, y montarlo archivo a archivo (no como carpeta) en -v .../claude-code-home.json:/root/.claude.json. A diferencia de los demás volúmenes de este setup, este necesitó sembrarse con contenido real primero — un bind mount de archivo con un origen que no existe todavía en el host termina creando un directorio vacío en su lugar, no un archivo, y rompe el arranque del contenedor. Verificado en vivo: tras recrear el contenedor con este mount, claude arrancó directo en la sesión normal, sin volver a pedir el diálogo de confianza.

Sobre paquetes: alpine (imagen alpine sin pin de versión, la misma que uso este setup) sí trae kubectl en sus repos — no hizo falta cambiar de imagen base ni instalarlo por binario aparte, contrario a lo que se sospechaba inicialmente. nodejs/npm (v24/v11 al momento de escribir esto) también están en los repos de alpine y cumplen de sobra el mínimo que pide Claude Code.

Nota sobre --network host + docker.sock: el contenedor tiene, en la práctica, control equivalente a root sobre el NAS (puede lanzar cualquier otro contenedor con cualquier mount). Es una concesión deliberada — es exactamente lo que hace falta para seguir operando Docker/k3d desde dentro — no un descuido.

Reconexión: un solo comando

/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-session.sh

El script (scripts/lab-session.sh) es intencionalmente el mismo comando sin importar el estado previo:

docker exec -it "$CONTAINER" tmux attach -t "$SESSION" || \
  docker exec -it "$CONTAINER" tmux new -s "$SESSION"

Si la sesión lab existe, se adjunta a ella (mismo shell, mismo claude corriendo, mismo directorio de trabajo). Si no existe —por ejemplo justo después de que el contenedor se recreó desde cero— la crea. No hay que recordar cuál de los dos casos aplica.

Chequeo de salud automático (cada 30 min)

scripts/tmux-host/healthcheck.sh corre en background dentro del propio contenedor (lanzado por entrypoint.sh, el ENTRYPOINT de la imagen) y cada HEALTHCHECK_INTERVAL_SEC (1800s por defecto) hace dos chequeos independientes, con una respuesta distinta según el tipo de falla:

  • Conectividad de red real (curl a https://gitea.cruzcloud.net/): si falla, solo se registra en el log y se notifica por Telegram — el contenedor nunca intenta "arreglar" la red del host. Un problema de red puede ser de hardware/BIOS (deuda técnica ya conocida, ver incidente-medusa-crashloop-2026-08.md), fuera del alcance real de un contenedor.
  • Existencia de la sesión tmux lab (tmux has-session): si no existe, sí se recrea automáticamente (tmux new-session -d -s lab -c <WORKDIR>) — es solo recuperar el propio entorno de trabajo del contenedor, no tocar infraestructura externa, así que es seguro hacerlo sin intervención humana.

Todo evento (chequeo ok, falla de red, sesión recreada) queda en /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log — persistente en /DATA, no solo en el stdout del contenedor (que se pierde con docker logs al recrearlo). Las notificaciones de Telegram reusan el mismo bot/chat_id ya configurado para scripts/lab-agent/ (lab-agent.env), en vez de duplicar credenciales entre los dos agentes del lab.

Para probar el intervalo sin esperar 30 minutos, se puede levantar el contenedor con -e HEALTHCHECK_INTERVAL_SEC=60 (solo para pruebas en un contenedor descartable — nunca cambiar esto en el tmux-host real sin volver a 1800 después).

Qué sobrevive a qué

Evento ¿Sobrevive la sesión tmux (lab)? ¿Sobrevive el login de Claude Code?
Corte de SSH / cerrar la terminal / cambiar de dispositivo Sí — es exactamente el caso que este setup resuelve Sí
docker restart tmux-host (reinicio del NAS) No al toque, pero se auto-recupera solo — el proceso tmux server muere con el contenedor; healthcheck.sh la recrea en el próximo ciclo (máx. 30 min) sin intervención, o lab-session.sh la crea al instante si alguien se reconecta antes Sí, vía el volumen montado en /root/.claude
docker rm + recrear el contenedor desde cero No, pero igual se auto-recupera sola (mismo mecanismo de arriba) Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de claude-code-home.json)
Falla de red real del host/NAS N/A N/A — healthcheck.sh la detecta y notifica, pero no la corrige (fuera de su alcance)

Validación en vivo

Confirmado en este mismo lab (2026-08-27): se dejó una variable de entorno (export MARCA_PERSISTENCIA=ok_<timestamp>) corriendo dentro de la sesión lab vía tmux send-keys, y una invocación completamente separada de docker exec (equivalente a una nueva conexión) leyó de vuelta el mismo valor — confirma que el estado vive en el servidor tmux del contenedor, no en el cliente que se conecta. claude --version respondió 2.1.250 dentro de esa misma sesión, y un archivo de prueba escrito en /root/.claude dentro del contenedor apareció de inmediato en /DATA/AppData/k3d-lab-v3/claude-code-home en el host, confirmando el bind mount de credenciales.

Después de agregar el mount de /media/HDD-Storage y el safe.directory, se validó además: md5sum de CLAUDE.md idéntico leído desde el host y desde dentro del contenedor (vía el path con symlink /DATA/AppData/...), y git log/git status corriendo sin error sobre apps-registry y scripts desde una shell dentro de tmux-host.