Lección 23 · 24 en la ruta · 17 min de lectura · Nivel 3
Visor v3, copia web y bandeja
Visor con dos costos (equivalente API y cuota) e historial propio que no se borra; copia web redactada con lista de permitidos; bandeja para dejar tareas desde el celular que la PC recoge, valida y encola
Objetivo
Un visor local con dos costos siempre a la vista y un historial que no se borra; una copia web redactada del visor y una bandeja para dejar tareas desde el celular. Las dos se probaron primero en local y se desplegaron el 2026-10-07 (sección E).
Quién lo hace: Claude Code (2026-10-07). Tú: la prueba desde el celular (NEEDS-OWNER §19, punto 2).
Estado, para no confundir lo medido con lo supuesto:
| Parte | Estado |
|---|---|
| Visor v2/v3 local | hecho y en uso en win |
Copia web (visor.ichisieben.dev) | construida y probada contra PHP local; desplegada el 2026-10-07 (sección E) |
| Bandeja | probada de punta a punta en local con la Mac real; desplegada el 2026-10-07, ronda de bandeja ok; falta la prueba desde el celular |
Por qué: tirar, redactar con lista de permitidos, firmar
Tres ideas, una por parte:
- Tirar en vez de empujar. Hostinger no puede llamar a casa; la PC pregunta cada pocos minutos. Ficha: Pull vs push.
- Lista de permitidos. Lo que sale a internet no es “todo menos lo secreto”, sino “solo lo que se copió campo a campo”. Ficha: Permitidos vs prohibidos.
- Firma sobre el propósito. Cada petición lleva un HMAC que solo vale para un endpoint. Diseño completo de la bandeja: BANDEJA.md; aquí no se repite.
Pasos
Las secciones A) a E) son los pasos, en el orden en que se hicieron.
A) Visor v2/v3 (local)
Diseño
- Sistema de diseño del sitio de portafolio del owner: oscuro por defecto, Space Grotesk y
JetBrains Mono incrustadas como URIs
data:. Sin dependencias externas, así que sirve con una CSP estricta y sin red. - Jerarquía de arriba abajo: en vivo → consumo → proyectos → modelos de la Mac → historial.
- Siempre dos costos, nunca uno solo:
- Equivalente API: un piso, no una factura. Precio de lista × tokens, incluidas las escrituras de caché (5 min = 1,25× el precio de entrada; 1 hora = 2×). Está marcado como estimado.
- Cuota: el % de la suscripción (ventanas de 5 h y 7 d).
Historial propio
Cada nodo tiene su base SQLite en ~/.enjambre/visor/history.db (tablas files, msgs,
sessions, meta). Nunca se poda: cuando Claude Code borra los .jsonl viejos, el historial
sigue ahí. Reglas:
- El
history.jsonanterior se importa una sola vez. - Las copias
~/.claude.bak-*se leen como raíces extra. Si la misma sesión está en dos raíces, cuenta una vez: gana el transcript principal más largo, y las respuestas se deduplican por id de mensaje.
Resultado real en win:
| Medida | Valor |
|---|---|
| Transcripts leídos | 498 |
| Sesiones | 149 |
| Rango | 2026-08-13 → 2026-10-07 |
| Tiempo por pasada | 2 s |
| Sesiones que solo existían en la copia de respaldo | 31 |
| Filas de respuesta / ids distintos | 30 902 / 29 305 |
Semáforos
Los umbrales salen de ~/.claude/rules/thresholds.json, no de números recordados:
| Semáforo | Ámbar | Rojo |
|---|---|---|
visor-disk | 80 | 90 |
visor-mem | 80 | 92 |
visor-quota-5h | 70 (visor-quota-5h-amber) | 90 (quota-5h) |
visor-quota-7d | 70 | 90 |
| trabajos fallidos / incompletos | — | cualquiera > 0 |
Llevan hint:false: la línea de estado los ignora (métrica desconocida = -1, nunca dispara).
Si el archivo no se puede leer, el semáforo sale gris con “sin umbral”; nunca un verde
inventado.
De Windows a la Mac: qué sesión mandó el trabajo
enjambre run guarda origin_session (de la variable CLAUDE_CODE_SESSION_ID) en el ledger y en
la cola. Los trabajos que despacha watch reciben ENJAMBRE_ORIGIN_SESSION desde job.json,
para no heredar la sesión del propio watch.
Íconos y gráficos
- Íconos de tecnología: del sprite del portafolio (
public/icons/skills.svg, Simple Icons, CC0), emparejados por nombre de carpeta, nombre del repo de la tarjeta o alias enconfig/visor-web.toml. - Gráficos: barras apiladas en SVG hechas a mano, por día y por máquina. Un solo tooltip delegado (hover, foco y toque). Sin biblioteca.
(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
Antes y después a 390 px de ancho, redactados con la lista de permitidos de --demo.
(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
B) Copia web en visor.ichisieben.dev (construida en local; desplegada en E)
Se construyó y se probó en local antes de subir nada. El despliegue se hizo el 2026-10-07 con el OK explícito del owner (sección E).
Dos muros contra las fugas
- Lista de permitidos (
web.public_snapshot): cada campo se copia de forma explícita. Lo que no está escrito ahí no sale.- Nombres de proyecto → alias estables
proyecto-xxxxx, por HMAC con una sal aleatoria local. - Discos →
disco-N; montajes →recurso-N; tmux, solo un conteo.
- Nombres de proyecto → alias estables
leak_check: segundo muro que bloquea la subida si encuentra palabras restringidas, cadenas con forma de ruta, IPv4, o nombres privados (los dessh -Gy el usuario local).
Subida y acceso
- La subida es un POST firmado cada 10 min desde la tarea del visor que ya existía.
Firma: HMAC-SHA256 sobre
propósito\nts\ncuerpo, contsen milisegundos de época, ventana de ±300 s y monótono por propósito: una repetición recibe 409. - La página está protegida con Basic auth (
.htaccess), cabecerasnoindexyrobots.txtconDisallow. data.phpfalla cerrado y usaREMOTE_USER, nuncaPHP_AUTH_USER: un cliente puede falsificar este último si el.htaccessno se aplicara.
Empaquetar
PS> python tools/web/build_dist.py
Salida: state/web-dist/visor-app y visor-app.zip, 9 archivos. index.html no lleva datos.
C) Bandeja: del celular a la Mac
Resumen del recorrido; las tablas del contrato, las firmas y los estados están en BANDEJA.md.
- El formulario guarda una tarea firmada fuera del docroot.
- Dentro de
watch, la PC pregunta cada 2 min con un GET firmado (poll). - La PC verifica el HMAC sobre los bytes guardados, antes de parsear.
- Vuelve a aplicar todas las reglas, sin fiarse del PHP:
- lista cerrada de repos;
kindque exista también enrouting.toml;- lista de workers;
- regex de rutas, sin
..; - las palabras restringidas, de forma estructural en rutas, y con límite de palabra en texto libre (así “análisis” pasa);
- límite diario por día de Lima.
- Escribe
TASK-<id>.mdy encola en enjambre. Nunca un shell: el único argv esenjambre.py run --repo <de la lista> --task <ruta generada>. - Mismo
worker.shcon el mismo--allowedTools,--max-turns 8porENJAMBRE_MAX_TURNS, sin bypass y sin reintentos (la tarea se marca vista antes de encolar). - Estados
recibida / corriendo / hecha / fallida, con rama y un resumen de 500 caracteres que pasa por el mismoleak_check.
Pruebas
- 14 unitarias: firma válida e inválida (incluido un cuerpo alterado), repo fuera de la lista,
rutas sensibles, duplicado que nunca se encola dos veces, límite diario y su reinicio al día
siguiente, Mac caída (la tarea se queda en
queue/), rechazo del enrutador, avance hastahecha. - 4 de integración contra el PHP real bajo
php -S: falla cerrado sin usuario ni firma, repetición 409, propósito equivocado 401, fuera de ventana 401, formulario → PC → de vuelta, y subida + lectura de datos.
De punta a punta, en local
Formulario → PC → Mac con el worker stub-ok ($0) → estado “hecha”. Rama
mac/b20261007-653a883f subida, resumen “stub ok”, unos 62 s en total.
(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
Qué salió mal de verdad (lo que falló)
- El antivirus borró el router de prueba. El
php -Sde pruebas usaba un script router que hacíarequirede una ruta derivada de la petición; el antivirus lo tomó por una webshell, lo borró y matóphp -Sa mitad de prueba. Arreglo:-d auto_prepend_filecon un archivo de una línea que copia una cabecera de prueba aREMOTE_USER, escrito en un directorio temporal. - El cliente de la PC aceptaba cualquier HTTP 200, incluida una página de error de PHP.
Ahora exige
{"ok":true}. - Falso positivo de IPv4. El segundo muro bloqueó la carga porque la ruta SVG del logo de
macOS contiene
2.774.6.926.466, que parece una IPv4. Arreglo: los íconos de sistema operativo pasaron a la plantilla HTML, no a los datos. El muro hizo su trabajo; el dato estaba mal ubicado. - PHP portable sin
mbstring. Se añadió uncut()de respaldo conpreg.
C2) Segunda vuelta del visor
Objetivo: que el visor hable de aparatos y no de ids, que cuente el gasto por semana y por mes, y que cada trabajo, modelo y subagente se pueda abrir y filtrar.
Por qué así: un visor que dice win y mac obliga a saber cómo está configurado el enjambre;
uno que dice “PC” y “Mac mini” se entiende sin saberlo. Los datos ya existían casi todos (el
ledger, measurements.jsonl, los .meta.json de los subagentes); faltaba leerlos completos en
vez de quedarse con el último.
-
Etiquetas de aparato en
nodes.toml:[nodes.win] label = "PC" [nodes.mac] label = "Mac mini" # [nodes.laptop] # cuando entre: label = "Laptop" -
Logos oficiales a color (devicon, MIT), vendorizados y limpiados por
tools/visor/icons/vendor_devicon.py. Donde no hay versión oficial, o se pierde en el tema oscuro, queda el glifo de color de marca del Landing. La interacción es la de las fichas del Landing: brillo del color, logo que se inclina y tooltip con qué es cada tecnología.$ python -I tools/visor/icons/vendor_devicon.py ok python 2345 bytes ok githubactions 2797 bytes skip duckdb HTTPError <- devicon no lo tiene: queda el glifo de marca ok linux 198906 bytes <- Tux: demasiado pesado, se descartó a mano -
Consumo: hoy, luego 7 días, luego todo el historial, cada uno con su promedio diario. Además, una tabla y un gráfico por semana ISO y por mes.
(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
-
Modelos locales: cada medición del banco con fecha y hora, y los trabajos del gestor, con filtro por tarea y por fecha. El banco de
modelos-macguarda ahora la hora (ts) en cada fila nueva. Para una fila vieja, el visor usa la hora de su archivo de resultado solo si hay uno que coincide, y la marca con~.(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
-
Historia: todos los trabajos del ledger y los trabajos largos de cada aparato, filtrables por proyecto. Al tocar uno se abre su detalle: estado, aparato, rama, coste, motivo de ruta, resultado y log. Un trabajo largo (
jobrun.py) trae latido real: su supervisor reescribestatus.jsonen cada latido. Uno corto muestra la última señal, derivada de la hora del archivo más nuevo de su carpeta en la cola.(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
-
Por proyecto: hilos (conversaciones), subagentes (tipo y descripción, leídos del
.meta.jsonde cada uno) y trabajos del enjambre. Tocar una fila la marca en la línea de tiempo.(captura del entorno real: no se publica; ver docs/CAPTURAS.md para las del modo demo)
-
PWA preparada y apagada:
tools/web/pwa/(manifest e iconos) y[web] pwa = false. Contrue,build_dist.pylos incluye y enlaza el manifest.
Instalar el colector nuevo en la Mac y actualizar modelos-mac allí:
PS> scp tools/visor/collect.py mac:.enjambre/visor/collect.py
$ ssh mac 'launchctl kickstart gui/$(id -u)/dev.enjambre.visor'
last exit code = 0
$ ssh mac '~/.enjambre/tmux-run.sh -C ~/Documents/Portfolio/modelos-mac -t 150 -- git pull --ff-only'
73fc382..eac80df main -> origin/main
Resultado medido: 44 mediciones en la Mac; la copia web pasa el muro y pesa 136 KB (antes 64 KB;
45 KB son subagentes). Sin errores de JavaScript, sin desplazamiento horizontal a 390 px y ningún
win/mac suelto en el texto (comprobado con Playwright).
Lo que falló:
- El primer recorrido mostró “Mac” en vez de “Mac mini”.
gather()copiaba solo algunas claves de cada nodo ylabelno estaba entre ellas. Una prueba lo cubre ahora. - La primera versión de la lista de trabajos dejaba fuera los
stub-okpor ser “de prueba”, y con eso desaparecía justo la prueba de la bandeja desde el celular. Ahora entran todos. - Las filas viejas del banco solo tenían fecha. No se inventó la hora: “hora no registrada” cuando hay más de un archivo candidato.
- La primera versión pintaba los logos de un solo color, como el Landing, y el pedido era “los SVG oficiales, no monocromo”. Se vendorizaron los oficiales. Astro y three.js volvieron al glifo de marca porque son negros y no se ven en el tema oscuro.
- La revisión de ids sueltos leía la página antes de abrir los detalles y por eso no veía el motivo de ruta (“tests -> mac: …”). Ahora abre cada trabajo y revisa también el tooltip.
D) Modelo de amenazas en una página
| Quién | Qué tiene | Puede | No puede |
|---|---|---|---|
| Alguien con solo la URL | Nada | Ver un aviso de login; robots y noindex lo mantienen fuera de buscadores | Leer datos ni tareas |
| Quien tiene la contraseña Basic | Acceso a la web | Leer la instantánea redactada (alias, conteos, costos; sin rutas, nombres ni IP). Crear tareas, pero solo contratos de repos, kinds y workers listados, con el tope diario de la PC, con herramientas restringidas, en una rama y sin fusión automática | Ejecutar comandos de shell, tocar rutas de repos restringidos, llegar a la red de casa |
| Quien tiene el token de la bandeja (clave HMAC) | La clave de bandeja_key | Falsificar tareas con firma válida; marcar estados de tareas | Salirse de las listas cerradas ni del límite diario de la PC; saltarse worker.sh. Mitigación: rotar la clave en config.php y en la variable de entorno de Windows |
| Quien tiene la clave de subida | La clave de ingest_key | Sobrescribir la instantánea mostrada (desinformación) | Crear tareas: hay una clave distinta por propósito |
| Quien compromete la cuenta de Hostinger | Todo lo anterior | Todo lo anterior | Entrar a casa: la PC solo pregunta, nada escucha en la red de casa |
Qué prueba la firma: que la tarea la escribió el PHP que tiene la clave. Qué no prueba: que la escribiste tú. Por eso las reglas de verdad corren en la PC.
E) Parte 2: el visor en internet (2026-10-07)
Objetivo: https://visor.ichisieben.dev detrás de contraseña, con la bandeja abierta al celular.
Por qué en este orden: primero se sube el código y se comprueba que falla cerrado (sin
contraseña ni config.php, todo responde 401 o 503); recién después se ponen los secretos. Si un
paso sale mal, lo que queda expuesto es una página que no responde, no un visor abierto.
-
Subdominio con la API de Hostinger (
hosting_domains_create-website-subdomain,subdomain: visor,directory: visor-app). Queda enpublic_html/visor-app, al lado deradar-appymetal-app. PHP del sitio: 8.3. -
Subida archivo por archivo (TUS,
hosting_files_generate-upload-url), solo dentro devisor-app/. El deploy “por zip” de la API reemplaza el sitio padre, que aquí es el Landing: por eso no se usa.$ python tools/web/build_dist.py && python tus_up.py ok 201/204 1455 .htaccess ok 201/204 194 api/.htaccess ... ok 201/204 766 pwa/manifest.webmanifest -
Matriz de verificación sin credenciales:
https://visor.ichisieben.dev/ 401 https://visor.ichisieben.dev/data.php 401 https://visor.ichisieben.dev/lib.inc.php 403 https://visor.ichisieben.dev/api/tareas.php 503 {"error":"sin configurar"} https://visor.ichisieben.dev/pwa/manifest.webmanifest 200 application/manifest+json https://ichisieben.dev/visor-app/ 401 (la ruta por el dominio padre también) http://visor.ichisieben.dev/ 301 (lo hace Hostinger antes que el .htaccess) -
Claves: un script las genera, las guarda como variables de entorno de usuario de Windows y escribe
config.phpen una carpeta local fuera del repo. Ningún valor se imprime ni pasa por un comando. -
Carpeta privada, contraseña y
config.php, todo por la API (cambio de plan del owner: sin hPanel). La API no escribe fuera depublic_html, así que la carpeta espublic_html/visor-private, cerrada con su propio.htaccess. El orden importa: primero ese.htaccessy la comprobación del 403; recién entonces los secretos.1. visor-private/.htaccess ok 201/204 222 visor-private/.htaccess ok https://ichisieben.dev/visor-private/.htaccess 403 2. config.php ok 201/204 379 visor-private/config.php ok https://ichisieben.dev/visor-private/config.php 403 4. .htpasswd (user ichi, bcrypt $2y$) + visor-app/.htaccessLa contraseña (20 caracteres al azar) se genera en memoria, se convierte en bcrypt con el
password_hashde PHP y solo se muestra una vez al owner. La ruta deAuthUserFilesale delroot_directoryque devuelve la API, no de una suposición. -
Matriz final:
/ con contraseña 200 / sin contraseña 401 / contraseña equivocada 401 visor-private/.htpasswd 403 visor-private/config.php 403 data.php con contraseña 200 (144 884 B, después de la primera subida firmada) -
Prueba firmada con las claves leídas del registro:
build.py→web: ok 144884 B;bandeja.py --once→{"bandeja": "ok", ...}y la página de la bandeja ya muestra la lista permitida (enjambre-sandbox,stub-ok).
Nota posterior: la carpeta privada se movió a domains/ichisieben.dev/private/visor, fuera de
public_html, porque un deploy del Landing borraría public_html/visor-private (DECISIONES 224).
Lo que falló:
- El manifest daba 401: un
FilesMatchconRequire all granteddentro del.htaccessraíz no le gana aRequire valid-useren LiteSpeed. Se movió apwa/con su propio.htaccess(Satisfy Any), el mismo truco que ya usabaapi/, y pasó a 200. - La API de archivos no deja salir de
public_html(..→ 422). - La contraseña correcta daba 401. Primero se descartó la caché del CDN (
?cb=al azar, igual 401). Después, usuarios de prueba con cinco formatos de hash: los cinco funcionaban, bcrypt incluido. El error era nuestro: en Windows,subprocesscontext=Trueconvierte el\nque se manda por stdin en\r\n, y se había hecho el hash de la contraseña más un\r. Arreglo: mandar bytes, sin salto de línea. - El token de subida dejó de valer antes de su
exp(403 en todo, también en un archivo inofensivo). Se pidió uno nuevo; conviene pedirlo justo antes de cada tanda. - Python decía «certificate has expired» con un certificado emitido hoy. Pasa con todos los
subdominios desde esta PC: el almacén de Windows hace que OpenSSL arme una cadena con un
certificado vencido. Con el paquete de
certifiverifica bien; se usa ese y la verificación sigue encendida.
F) Pendiente del owner
El reinicio de watch está hecho (2026-10-10, pid 46420). Falta la prueba desde el celular:
NEEDS-OWNER §19, punto 2.
Cómo verificar
PS> python tools/web/build_dist.py
Salida esperada: state/web-dist/visor-app y visor-app.zip, 9 archivos, con index.html sin datos.
Con el visor desplegado, la matriz sin credenciales de la sección E debe dar:
https://visor.ichisieben.dev/ 401
https://visor.ichisieben.dev/data.php 401
https://visor.ichisieben.dev/lib.inc.php 403
https://visor.ichisieben.dev/api/tareas.php 503 (antes de poner config.php)
Con contraseña, / da 200 y data.php da 200. Prueba firmada: build.py imprime web: ok <bytes> B y bandeja.py --once imprime {"bandeja": "ok", ...}.
Autoevaluación
- ¿Por qué la copia web usa lista de permitidos y no una lista de prohibidos?
Respuesta: lo que sale a internet es solo lo que se copió campo a campo; lo que no está escrito no sale.
leak_checkes un segundo muro que bloquea la subida. - ¿Por qué la PC verifica la firma antes de parsear y vuelve a aplicar todas las reglas? Respuesta: la firma prueba que la tarea la escribió el PHP que tiene la clave, no que la escribiste tú; por eso las reglas de verdad corren en la PC.
- ¿Por qué se suben los archivos uno por uno y no por zip? Respuesta: el deploy por zip de la API reemplaza el sitio padre, que aquí es el Landing.