13ce9b16f1d8d838f357381f93c49c0a09272d82
533
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
af20d90648 |
bzip2: cuatro comandos rotos desde siempre — /usr/bin/bzcmp -> /out/usr/bin/bzdiff
`bzcmp`, `bzegrep`, `bzfgrep` y `bzless` son symlinks con **la ruta del SANDBOX horneada dentro**. El Makefile de bzip2 los crea con `ln -s $(PREFIX)/bin/bzdiff bzcmp` —con `$(PREFIX)` DENTRO del destino— y como bzip2 **no soporta `DESTDIR`**, la receta pasa `PREFIX=/out/usr` (el mismo truco que valkey) ⇒ el destino que queda es `/out/usr/bin/bzdiff`. Al hidratar, esos cuatro apuntan a un directorio que en el sistema real no existe. ⚠ **Cinco indicadores en verde sobre cuatro comandos que no funcionan**: el artefacto tiene contenido, `bzip2`/`bzgrep`/`bzdiff` corren, `static-audit` pasa, el grafo lo cuenta y la receta REPRODUCE. Ninguna métrica existente mira a dónde apunta un enlace. Arreglo: rehacerlos RELATIVOS después del install, que es lo correcto para un artefacto direccionado por hash — un enlace relativo sigue valiendo esté el árbol montado donde esté. Verificado: los cuatro resuelven y `bzip2 -c | bzcat` sigue dando `hola`. ## El guardián, en el mismo sitio y por la misma pregunta `--auditar-raices` ya contestaba «¿esta receta dejó ficheros donde no van?». Ahora contesta también la otra mitad: «¿dejó ENLACES a una raíz que no es del FHS?». Son la misma familia —un `install` que se equivocó de destino— y comparten la definición de `FHS_RAIZ`, que es lo que evita dos listas que se desincronizan. La regla es independiente del perfil, y por eso vale sobre el store entero: un enlace a OTRO artefacto es normal (`kinfocenter -> /usr/bin/systemsettings`, los dos en `escritorio-kde`, resuelve en el rootfs fundido). Lo que nunca puede estar bien es un destino absoluto cuya primera componente no sea del FHS: `/out`, `/src`, `/tmp` son rutas del lab. Dos correcciones al propio guardián, las dos aprendidas midiendo: · **Sólo audita el artefacto VIGENTE de cada receta.** El store guarda todos los sellados históricos, así que la primera versión acusaba a bzip2 por el artefacto YA SUPERADO — el mismo sobre-reporte que `static-audit.sh` tuvo que quitarse de encima. El hash vigente sale de los grafos de estado, no de 1150 llamadas a `takana hash`. ⚠ Y hay que regenerar **los cinco** grafos: con sólo el principal regenerado, el de KDE seguía apuntando al bzip2 viejo y el filtro lo dejaba pasar. · **Un barrido que no miró NADA lo dice y sale 2.** Filtrar por hash vigente hace que un `--store` que no case con los grafos deje cero artefactos auditados, y sin la guarda eso se imprimía como «✓ 0 ofensores»: una respuesta falsa con forma de respuesta. Probado: `--store /tmp` → exit 2. El control del hallazgo es el propio arreglo, sobre datos reales y no sintéticos: antes del fix el barrido nombra los cuatro enlaces de bzip2; después, `✓ ninguno`. |
||
|
|
0f58df4795 |
planear: el preflight medía contra / — y los datos de gioser no entran ahí
La cadena `censar → planear → aplicar` corrió entera contra gioser por primera vez: 59 pasos, 20
ejecutables y 39 manuales, bien separados. El ensayo en seco destapó lo que ninguna prueba de juguete
iba a mostrar: **33,6 G de datos contra una raíz con 3,5 G libres** (el sitio está en `/work`, 66 G).
Dos cosas estaban mal a la vez:
· **El plan copiaba ruta → MISMA ruta**, sin forma de decir dónde cae cada árbol en el destino. Ahora
`[[datos]]` tiene `destino` (vacío = la misma ruta). El fallo que evita es caro: aparecía a mitad
de un rsync de 22 G.
· **El preflight sumaba todo y lo comparaba contra `/`.** Acierta POR CASUALIDAD mientras todo caiga
en `/`, y da un veredicto completamente falso en cuanto una ruta va a otro montaje — en las dos
direcciones. Ahora mide por sistema de ficheros, agrupa los destinos, nombra qué rutas caen en cada
uno, y el veredicto lo saca el propio comando en vez de un humano leyendo una columna.
Los dos controles, contra la caja de verdad:
✗ / necesita 34386 MiB · libres 3596 ⇐ /var/www /var/lib /srv /opt /home exit 1
✓ /work necesita 34386 MiB · libres 66192 ⇐ /work/var/www … /work/home exit 0
Además, el paso `muere` ahora cumple la regla 2 ENTERA («por su nombre Y CON SU TAMAÑO»). Un dominio
fósil no pesa nada por sí mismo: pesa lo que dejó en disco, y `terapeuta.ec` —que no resuelve en
ningún DNS— tiene 279 M en `/var/www/terapeuta`. Que eso se supiera dependía de que alguien se
acordara. Se lista un renglón POR DIRECTORIO (siete dominios `*.gioser.net` reclaman el mismo
`gioser.bak`; repetirlo siete veces convierte el aviso en ruido) y se dice «podría ser de», nunca
«es»: el directorio se llama `terapeuta` y el dominio `terapeuta.ec`, y emparejar «casi» acierta casi
siempre y el resto de las veces manda a borrar lo que no era. Los directorios que no reclama nadie se
listan aparte: son los que nadie recuerda.
⚠ Y un bug que me hice yo y que el control cazó: empalmar por `str.index('# ── 1. datos ───')` cuando
ese marcador existe en DOS funciones. Tomó el corte al revés (j < i) y DUPLICÓ `def pasos` entero;
Python se queda con la última definición, así que el fichero importaba bien y generaba planes con el
preflight viejo. Se vio porque el comando del plan no era el que yo acababa de escribir. Un marcador
de empalme que no es único no es un marcador.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
bd1fa8bf8a |
boot entry backup/restore: el arranque como estado reproducible (ADR 0018 §5)
Guarda las variables de NVRAM CRUDAS —se reescriben tal cual; decodificarlas para volver a codificarlas al restaurar sería una oportunidad de perder algo que no entendemos— y de la ESP un manifiesto con hashes, no los bytes: el kernel ya vive en el store y copiarlo otra vez sería churn. El manifiesto es evidencia de qué había; para lo que no esté en el store dice qué falta, en vez de prometer reponerlo. El ADR se equivocaba en DÓNDE: decía "volcar al store". No va al store, porque lo barre store-gc.sh, que clasifica por nombre de receta — un respaldo ahí sería huérfano y se borraría en el primer --huerfanos. Vive en /var/lib/hammer/boot/, que en las imágenes es su propia partición. El nombre sale del CONTENIDO, así que un arranque que no cambió produce el mismo fichero: corre en cada arranque sin llenar la partición de copias. Lo que encontró la prueba de punta a punta y no estaba en el diseño: restaurar es volver al pasado, y lo que llegó DESPUÉS no estaba en ese pasado. Al reponer el BootOrder del respaldo, el Windows instalado más tarde quedaba FUERA del orden — correcto, y justo lo que el usuario no espera de algo llamado "restaurar el arranque". Ahora se avisa antes, con nombre y apellido, y restore NO escribe por defecto: la NVRAM es lo único de la máquina que no se rehace desde el store. El lector FAT ganó lectura de ficheros, con su trampa propia: hay que truncar al tamaño DECLARADO en el directorio, no al final del último cluster. Un fichero de 1,5 MB en clusters de 1 KiB termina con relleno y hashear el relleno daría un hash distinto al del mismo fichero en disco — el síntoma sería "dos respaldos del mismo arranque difieren". Verificado contra mtools como oráculo (1 500 000 y 900 000 bytes exactos, mismo sha256 que los originales) y con un test determinista que fabrica una FAT16 a mano, sin depender de que mtools esté. 79/79 del CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
d0e3aadb12 |
declarar: /proc pasa a ser un lector del centro — y había DOS emisores de tarjetas, mal los dos
`declarar.py` tenía su propio molde de card de arje y `formatos/arje.py` el suyo: el N×M que el
pivote existe para evitar, adentro de mi propio código. Y no quedó en teoría — CADA COPIA TENÍA UN
CAMPO MAL DE LA RAÍZ, Y NINGUNO DE LOS DOS EL MISMO:
campo declarar.py arje.py semilla REAL del producto
provides ["Spawn","Journal"] ✓ [] ✗ ["Spawn","Journal"]
supervision Restart{…} ✗ "OneShot" ✓ "OneShot"
Las consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni dónde
escribir, y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.
Ahora hay un lector `proc` (el censo es un formato de origen, igual que systemd u OpenRC) y el
escritor `arje` es el ÚNICO que emite tarjetas; `declarar.py` queda con lo suyo, el perfil. Tres
lectores en la familia: systemd y openrc leen LO DECLARADO —que es lo que miente, `rc-status` daba
`stopped` para cinco servicios vivos— y `proc` lee LO QUE CORRE, que es donde aparecen los 15
`no-declarado`.
Dos cosas más, las dos sobre no mentir:
· **Una semilla vacía parecería un éxito.** Si ningún servicio tiene `decision = "muda"`, el lector
lo DICE y sale ≠0 en vez de emitir cero tarjetas en silencio. Mismo modo de fallo que un artefacto
vacío en el store.
· **El acta se ahogaba en su propio ruido.** Anotaba el `envp` vacío una vez POR SERVICIO: 26 líneas
idénticas que tapaban los dos hallazgos reales (`shuma-daemon` corre como `sergio`; los que no
tienen cmdline). La limitación es del lector y vale para todos ⇒ una entrada nombrando a los 26.
Un acta donde casi todo es la misma línea se deja de leer, y entonces no queda ningún acta. Y al
revés: las entradas que SÍ son por servicio ahora lo nombran (`gitea: cwd=/var/lib/gitea`).
Controles: la raíz generada coincide campo por campo con la del producto; dos corridas dan el fichero
byte a byte idéntico; 26 tarjetas con 26 ids únicos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
43a7ef0bd1 |
static-audit: auditaba SÓLO recipes/ y remataba con «toda receta lo cumple» — las 5 colas nunca se miraron
El audit del enlace estático globeaba `recipes/*.toml` y nada más, y cerraba con
«✅ toda receta que declara link=static lo cumple»: una frase verdadera de una PARTE del corpus,
presentada como si fuera de todo él. Las cinco colas (`incoming{,-kde,-gnome,-cosmic,-wlr}`) son
~305 recetas que **nunca se auditaron**.
Lo destapé por accidente: al promover `libseccomp` de `incoming-gnome/` a `recipes/`, **el artefacto
no cambió ni un byte** y el audit pasó de MIENTEN:0 a MIENTEN:1. La mentira estaba ahí desde siempre
y lo único que la tapaba era el glob de una línea. Un guardián que mide menos de lo que su resumen
afirma es peor que no tenerlo: da por cubierto lo que no mira.
Ampliado el barrido a las colas, aparecen **3** que llevaban invisibles:
libseccomp scmp_sys_resolver 6 rebuilds (corpus 1 + incoming-gnome 5)
libgcrypt dumpsexp y 2 más 41 rebuilds (corpus 1 + incoming-kde 40)
libgpg-error gpg-error 45 rebuilds (corpus 4 + incoming-kde 41)
Radios medidos con `yupana radio`, que cruza colas — no con `grep recipes/*.toml`, que es justo el
error que este commit arregla.
En las tres, lo dinámico es un **binario auxiliar de diagnóstico**, no la librería: los consumidores
enlazan el `.a`, así que el impacto funcional hoy es NULO. Lo que está mal es la declaración, y
arreglarla cuesta ~92 rebuilds casi todos de KDE. Van con el próximo bump de cada una, cuando el
re-hash ya esté pagado — mismo criterio que la deuda de `perl`. Decisión con el número delante, no
olvido.
Por eso entran en `DEUDA_DECIDIDA` y se imprimen como `•` sin hacer fallar: si el audit fallara
siempre por tres viejas, un ofensor NUEVO se perdería entre el ruido. El resumen ahora distingue
«MIENTEN (nuevos)» de «deuda decidida».
Probado con los dos controles y no sólo con el que da verde: quitándole a propósito el
`LDFLAGS=-all-static` a `crun` y reconstruyéndola, el audit sale **1** y la nombra; restaurada, sale
**0**. O sea que la tabla de deuda no se traga a un ofensor nuevo. El artefacto de la prueba se
borró del store.
|
||
|
|
2fa89ce3fa |
censar: tres nombres equivocados que llegaban al plan, y los 7 «binario desconocido» eran permisos
El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
CLASIFICACIÓN — y no son cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<n>` y el
que va de `label` en la tarjeta de arje.
· `comm` está capado a 15 caracteres: `willay-crosscheck` llegaba como `willay-crossche` y con ese
nombre no casaba contra su declaración ⇒ figuraba como `no-declarado` TENIENDO su tarjeta en la
semilla de arje. La línea de comando trae el nombre entero.
· `supervise-daemon` no es un servicio, es un ENVOLTORIO. Los cinco de gioser se fundían en una
entrada `supervise-daemo`; y del otro lado `dbus`, `metalog`, `dhcpcd`, `squid` y `shuma-daemon`
salían como `declarado-muerto` ESTANDO VIVOS — el mismo agujero que este censo existe para tapar,
entrando por la otra puerta. El nombre real es su argv[1] y el comando real es el último token
antes del `--` suelto (comprobado contra los cinco).
· `head -15` con ppid==1 era un resto de tubería reparentado, contado como servicio. Va a
`descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
**Y los 7 «no se pudo leer su binario» eran dos cosas distintas.** Muchos demonios reescriben su
`argv[0]` (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
comando no dice cuál es el binario — `/proc/<pid>/exe` sí, y necesita ser dueño o root. Medido:
uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
Ahora el censo DICE cuál de las dos pasó: «repetilo con sudo» y «averigualo a mano» son trabajos muy
distintos, y confundirlos manda a alguien a investigar un permiso.
Desenvolver al supervisor arregló además dos datos que salían falsos: el `exec` de `squid` era
`/usr/bin/supervise-daemon` (⇒ figuraba provisto por el paquete `openrc`), y el `cmdline` guardado
era el del SUPERVISOR — una tarjeta hecha con eso arrancaría `supervise-daemon` dentro de arje, que
ya supervisa. Y se rescata el `--user`: `shuma-daemon` corre como `sergio`, dato que la tarjeta de
arje no puede guardar (no tiene campo de usuario), así que ahora se avisa EN LA REVISIÓN — sin eso a
la vista, un servicio que acá corre sin privilegios termina de root en el destino.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
2201fac08e |
boot entry survey: el informe del terreno, leyendo la ESP SIN montarla (ADR 0018 §4)
Reporta firmware, discos, ESPs, con quién se comparten y si la
instalación entra. No escribe nada — y para poder prometer eso hubo que
leer la ESP sin montarla, que es lo que obligó al lector FAT de sólo
lectura (fat_ro.rs). Montar para averiguarlo pedía privilegios, dejaba
un efecto secundario justo cuando prometimos no tocar nada, y falla si
el vecino dejó la FAT sucia por su hibernación.
Sobre una ESP de fábrica (100 MiB) con Windows dentro:
FAT32 «ESP» — 100.0 MiB totales, 77.1 MiB usados, 22.1 MiB libres
vecinos en \EFI: Microsoft, BOOT
⚠ «Microsoft» ⇒ hay Windows en esta ESP. Es el que reordena
BootOrder al actualizarse.
✗ NO ENTRA: hacen falta 31.0 MiB y hay 22.1 MiB libres
Contrastado contra mtools como oráculo, que es lo que hace creíble el
número: mdir dice "23 221 248 bytes free" y el lector propio dice
22.1 MiB — el mismo. Los clusters libres se cuentan recorriendo la FAT y
NO se lee el FSInfo de FAT32 a propósito: ese campo lo deja
desactualizado un SO que desmontó mal, y un número optimista de más
haría fallar la instalación a mitad — justo lo que el §4 evita.
En el instalador el §4 resultó ser algo más que "cuánto espacio hay": la
rama UEFI se lleva el disco ENTERO, así que lo que hay que decir en voz
alta es con qué se lo va a llevar puesto. El survey corre antes de
particionar y nombra el \EFI\Microsoft si está. El usuario se entera
ANTES, y no después de que su Windows dejó de arrancar.
Dos cosas que habrían pasado inadvertidas sin test: el nombre largo
tiene que ganarle al 8.3 (sin juntar los LFN, "Microsoft" se lee
"MICROS~1" y la advertencia no dispara nunca), y el tipo de FAT sale del
número de clusters y no del texto del BPB, que es informativo y hay
formateadores que mienten. El primer test del tipo lo escribí mal —1999
clusters ES FAT12— y el síntoma es idéntico a un bug del lector.
78/78 del CLI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
|
||
|
|
beb1c2a597 |
auditar-raices: el guardián de la raíz sucia sólo veía los perfiles — y hay 649 recetas fuera
`hydrate-profile.py` ya tenía el bloque «quién ensucia la RAÍZ del rootfs», y es bueno: mira por ARTEFACTO y no sobre el árbol fundido, porque en el fundido el nombre del culpable ya se perdió. Pero sólo corre al hidratar un perfil ⇒ **sólo ve lo que alguna imagen declara**. Hoy hay **649 recetas que no alcanza ninguna imagen**, y una fase `install` que se equivoca de destino en una de ésas es invisible hasta el día que alguien la declare. Lo destapó `qdrant`: selló con **69 M** de cabeceras de protobuf bajo `/src`, y no está en ningún perfil ⇒ ningún guardián lo habría visto. Lo encontré mirando el árbol del artefacto a mano antes de promoverlo, que es justo lo que no se puede dejar a que alguien se acuerde. `--auditar-raices` hace la misma pregunta sobre el STORE ENTERO sin hidratar nada, reutilizando el mismo `FHS_RAIZ` (una sola definición de «qué puede ir en la raíz», no dos que se desincronizan). Y dos tablas, porque un barrido que canta tres cosas de las cuales dos son correctas se deja de leer: · `RAIZ_POR_CONTRATO` — `seed-zig` (el toolchain ES el artefacto) y los rootfs (`store`/`ente` son suyos por diseño). Se imprimen como ⊘ con el motivo y no cuentan. · `RAIZ_DEUDA_DECIDIDA` — `perl` y sus 945 páginas nroff en `/`. Es suciedad REAL pero su arreglo está decidido EN CONTRA por ahora (una línea, 305 rebuilds, va con el próximo bump). Se imprime como contexto y **no hace fallar**: si fallara siempre, un ofensor NUEVO se perdería entre el ruido del viejo — que es exactamente cómo se muere un guardián. Probado con los dos controles, no sólo con el que da verde: sobre un store de juguete con una suciedad inventada sale **1** y la nombra; quitándola sale **0**. Sobre el store real: 0 ofensores nuevos sobre 3 artefactos conocidos. |
||
|
|
54bf3e810e |
ia: el modelo de chat, pineado — Qwen2.5-1.5B-Instruct Q4_K_M (Apache-2.0)
Elegido por tres cosas, y las tres medidas antes de pinearlo: licencia Apache-2.0 (lo que una distro puede shipear sin letra chica, al revés que Llama-3.2 o Gemma), habla español —se le preguntó qué es una distribución de GNU/Linux y contestó dos frases correctas— y corre en CPU: 20,1 tokens/s en el hub sin GPU, 1,04 GiB. El objeto pineado es un tar que envuelve el .gguf, publicado en el mirror y servido por sha256: takana extrae toda fuente con `tar` y un GGUF pelado no lo es. Es el camino de firefox-pgo-profile. La receta anota el sha256 del GGUF DE UPSTREAM (el lfs.oid de HuggingFace, verificado al bajarlo) y no sólo el del tar nuestro, para que nadie tenga que confiar en nuestro tar. Round-trip verificado: apartados el caché y el artefacto, el build lo bajó del mirror y selló el mismo ArtifactHash. Guardián en la receta, porque el fallo es callado: un fichero truncado o un HTML de error renombrado a .gguf se instala igual y sella en verde. Se comprueban el mágico GGUF y el tamaño. ⚠ Y el modelo de verdad destapó una CARRERA en el guardián del §6.7: el censo de motores contaba en el instante del cierre — con el de juguete daba 0 y con el de la imagen daba 1, que se lee como fuga cuando en realidad matar un proceso con un giga mapeado tarda ~1 s. Ahora espera hasta 15 s y anota cuánto tardó. La rotura a propósito sigue fallando, ahora con el tiempo a la vista. Falta decidir en qué imágenes se declara (con el motor son ~1,25 GiB por perfil) y pinear el de embeddings (multilingual-e5-small) para la mitad semántica del §6.3. |
||
|
|
66eaf8738f |
el reconciliador ARRANCA solo: montar sysfs, y un diagnóstico que no mentía a medias
Cierra el §2 del ADR 0018, verificado con dos arranques de la imagen
completa en OVMF:
1º (por la fallback) → "ESP detectada en /dev/vda p1",
"⚠ EL ARRANQUE ESTABA CAMBIADO — takana lo repuso",
"faltaba la entrada «takana» ⇒ escrita en Boot0004",
"BootOrder: 0000,0001,0002,0003 → 0004,0000,0001,0002,0003"
2º → BdsDxe: starting Boot0004 "takana" from HD(1,GPT,923A070F-…)
/\EFI\takana\takanax64.efi
"✓ arranque en orden: Boot0004 «takana» ya es la primera"
Un sistema recién instalado se da de alta en el firmware en su PRIMER
arranque y desde el segundo arranca por su propia entrada, sin que nadie
corra un comando. Y el segundo no escribe nada.
Lo caro no fue el reconciliador sino un diagnóstico FALSO: el primer
arranque con el hook dijo "sin firmware EFI — esta máquina no arrancó
por UEFI" en una VM que SÍ arrancó por UEFI. La causa no tenía nada que
ver con UEFI: busybox switch_root no arrastra /sys —igual que no
arrastra /dev, cosa que el script ya contemplaba— así que el directorio
de efivars no existía. El mensaje mandaba a investigar el firmware, que
estaba perfecto.
Dos arreglos:
- el wrapper monta sysfs y después efivarfs. CONFIG_EFIVAR_FS=y ya
estaba en el .config SELLADO (verificado, no supuesto) ⇒ no se
re-hashea ningún kernel.
- el mensaje distingue TRES estados que se parecen: no existe (BIOS o
/sys sin montar), existe y está VACÍO (falta el mount, y lo dice con
el comando exacto), o tiene variables. Decir "no hay UEFI" cuando
falta un mount manda a diagnosticar al lugar equivocado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
|
||
|
|
236abe48f3 |
boot entry reconcile: converger con la NVRAM en CADA arranque (ADR 0018 §2)
La NVRAM es estado compartido y el vecino la reescribe sin coordinarse. Contra eso no sirve confiar: sirve converger. `reconcile` descubre su propia ESP, repone la entrada y el BootOrder, y es idempotente. Descubre la ESP por TIPO de partición (GUID de ESP en GPT, 0xEF en MBR), que es el único dato fiable sin montar nada — un reconciliador que monta sistemas de ficheros en el arranque es un efecto secundario que no queremos. Si hay VARIAS ESP no adivina: las lista y pide --disk. Elegir mal significa escribir una entrada que apunta a un disco que puede no estar, y eso es peor que no escribir nada. No rompe el arranque por nada: sin firmware EFI (máquina por BIOS) lo dice y sale 0. Y cuando actúa, GRITA — va a /dev/tty0 además del serial, a diferencia del menú de arranque. Un reconciliador que repara en silencio deja al usuario conviviendo con una rareza intermitente que no entiende; el mensaje dice explícitamente que si se repite en cada arranque es que otro sistema operativo le está reescribiendo la NVRAM. Enganchado al wrapper de PID1 de las dos imágenes, con el mismo timeout y el mismo || true que el menú: corre ANTES del exec de arje-zero y colgarse ahí es un arranque muerto e indistinguible de un kernel colgado. El test que vale es el escenario completo: takana se instala, llega el vecino y se pone primero, y el siguiente arranque lo repone — SIN borrar la entrada del vecino (takana se pone primera, no lo echa) — y el arranque siguiente ya no escribe nada. Más el GUID de ESP, que va en orden DE DISCO y no en el legible: escribirlo "como se lee" es el error clásico y no casaría con ninguna ESP real. 15 tests en el módulo, 74/74 del CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
73f75d45ed |
censar: sonda DNS-only (--probe-dns) — y los dominios fósiles vuelven a la lista de decisiones
Censo real de gioser (204.168.193.248, identificado por IP y no por hostname, que dice «momento»): 19 vivos, 18 NO-DECLARADOS, 85 declarados-muertos, 29 dominios. **El HTTP toca al origen; el DNS no.** Sondear los dominios desde la propia máquina disparó su fail2ban y la dejó incomunicada, así que el censo en local los salteaba — y quedaban 29 de 66 ítems SIN recomendación por una precaución correcta aplicada de más. Lo que dispara la jaula es el HTTP: `getent` le pregunta al DNS, no al servidor. `--probe-dns` sondea sólo el nombre, sin UNA SOLA petición a gioser, y contesta la pregunta que más pesa en una mudanza: cuáles siguen apuntando acá y cuáles son fósiles. 29/29 clasificados: 12 apuntan acá, 4 ya se mudaron, 13 NO RESUELVEN. Lo que el DNS solo no puede decir es si el backend contesta, así que ésos quedan en `apunta-aca`, clase propia y NO `vivo`: decir «vivo» sin haber pedido una página sería la respuesta falsa con forma de respuesta que este censo existe para evitar. **Y un hueco de verdad: los `fosil-sin-dns` quedaban fuera de `entradas()`**, con el argumento de que un dominio sin DNS no necesita ningún paso. Cierto para el DNS, FALSO para lo que arrastra: son 13 de 29, y `terapeuta.ec` tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran invisibles, así que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — el «mudar fósiles» que este plan existe para evitar, y contra su propia regla 2 («lo que muere se dice por su nombre y con su tamaño, ANTES de borrar nada»). Ahora entran, con recomendación `muere` y el aviso de revisar qué dejaron en disco. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
3aab82d6f6 |
planear: el centro de traducción ENGANCHADO al plan — y el plan que emitíamos no era TOML válido
El plan ya advertía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Cierto, y
la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora es un PASO
con su comando literal: `traducir.py --from openrc --to arje --in /etc/init.d/caddy …`.
Dos traducciones distintas por servicio, y confundirlas es caro: la DECLARACIÓN (cómo se levanta,
`openrc`/`systemd` → tarjeta de arje) y la CONFIGURACIÓN (qué hace, sólo si se eligió un
equivalente). Un servicio que se muda a sí mismo no necesita la segunda: ofrecérsela es inventarle
trabajo. Los pares se le PREGUNTAN al registro de plugins, no se listan acá — una lista propia se
desincroniza del centro y el plan ofrecería una traducción que no existe. Sin par, el paso lo dice.
La verificación es HUMANA a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir, así que
dar el paso por bueno por su código de salida sería al revés de lo que hay que mirar. Lo que verifica
es que alguien LEYÓ el acta.
**Y en el camino salió un fallo del producto: el plan no era TOML válido.** Apareció con el primer
comando multilínea, pero estaba latente desde el principio y tiene DOS modos:
· `awk "\$1==1"` —que está en el `verifica` de TODO servicio— con cadena básica da
`Unescaped '\' in a string`: el plan queda ILEGIBLE.
· `cmd --from x \` + salto: la cadena básica PARSEA y se come el salto y la sangría ⇒ el comando
que sale NO es el que se escribió, sin que nada falle. Ése es el peor.
El plan es el producto: se revisa, se versiona, se lleva a otro proveedor y se vuelve a correr. Uno
que no vuelve a parsear no sirve para ninguna de las dos cosas para las que existe. Arreglado con
cadena literal, y con un guardián que ESCRIBE Y RELEE antes de tocar el disco: si el TOML generado no
parsea, no se escribe nada. Convierte un fallo diferido —aparecía cuando alguien iba a EJECUTAR el
plan— en uno inmediato.
Probado de punta a punta: el comando que el plan emite, copiado tal cual, traduce el
`/etc/init.d/caddy` real de esta máquina a una tarjeta con `Restart{initial:3000}` (de su
`respawn_delay=3`) y reporta la única `reload()` que no se puede portar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
294959c1da |
arranque y GC: las dos rutas EFI, guardián de ESP y el kernel vivo como raíz
Primera tanda de los ADR 0017/0018, con las mediciones que la corrigieron. ADR 0018 §3 — install-image-efi.sh y takana-live-install.sh escriben el kernel en \EFI\takana\takanax64.efi ADEMAS de la ruta fallback, con un guardián de capacidad que comprueba ANTES y con los números a la vista (duplicar el kernel cuesta, y el costo se dice). En el live-install la verificación es por TAMAÑO, no por presencia: un cp truncado en FAT32 deja el fichero ahí y test -e diría que todo salió bien. Verificado con imagen real en OVMF: las dos copias dan el mismo sha256 que el bzImage del store, y la imagen arranca hasta arje-zero PID1. CONTROL NEGATIVO: pisando la fallback con 4K de basura el firmware dice "No bootable option or device was found" y no aparece HAMMER-EFI ni una vez ⇒ el escenario de Windows, reproducido. Y lo que NO se pudo verificar cambia el alcance, así que va en el ADR: sin entrada NVRAM el firmware NO busca el vendor path. La copia vendor es hoy un seguro que no se cobra solo — el §3 es necesario y NO suficiente sin el §1. Eso asciende la receta efibootmgr a bloqueante. ADR 0017 §4 — store-gc.sh suma como raíz el kernel EN EJECUCIÓN, identificado por .config byte a byte. Sin esto el artefacto del kernel vivo cae en "superados" cuando la receta se movió, y se borra: el sistema sigue andando perfecto hasta el día que hace falta volver atrás. Probado en los tres sentidos, incluido el control que TIENE que seguir condenado. Además, dos bugs preexistentes que aparecieron al ir a medir: - install-image-efi.sh abortaba con "ROOTFS sin /sbin/init" en TODO rootfs sano: /sbin/init es un symlink ABSOLUTO (→/usr/bin/arje-zero) y [ -e ] lo sigue contra la raíz del HOST. Un chequeo que validaba algo distinto de lo que creía validar. Arreglado resolviendo el destino dentro del rootfs. - (no arreglado, es del entorno) el cp -al del staging da EXDEV si ROOTFS y STAGE no están en el MISMO MOUNT — y el bind-mount del store cuenta como otro mount aunque sea el mismo /dev/sdb. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
4cb776ecd7 |
vigía: 46 herramientas selladas que NO SE PUEDEN INVOCAR — cuatro verdes sobre algo inerte
Hay una familia de recetas que no son programas: son SUBCOMANDOS. `protoc-gen-go` es un plugin que
ejecuta `protoc`; `cargo-audit` existe para escribirse `cargo audit`; `kubectl-tree` lo despacha
`kubectl`. Están selladas, tienen contenido, resuelven sus sonames, el grafo las cuenta y
`verificar-repro` dice que reproducen. **Cuatro indicadores en verde sobre binarios que no se pueden
usar**, porque el driver que los invoca no está en el catálogo.
Ninguna métrica existente puede verlo: la arista «me ejecuta aquél» NO es una dep de build, así que
no existe en el grafo. Lo descubrí por accidente — `protoc-gen-go` llevaba meses sellado y el
catálogo no tenía `protoc`; lo delató ir a escribir la receta de un servicio gRPC y preguntarme con
qué se generan los stubs. Eso es exactamente un punto ciego, y acá va su guardián.
Lo que mide hoy:
✗ falta `cargo` ⇒ 44 recetas INERTES (cargo-audit, cargo-nextest, cargo-deny, …)
✗ falta `kubectl` ⇒ 2 recetas INERTES (kubectl-neat, kubectl-tree)
TOTAL: 46. No es deuda de BUILD —construyen y reproducen— es deuda de CATÁLOGO.
Dos decisiones de diseño que son la diferencia entre un vigía que se lee y uno que se ignora:
· **Comprueba el BINARIO, no el nombre de la receta.** El driver de `protoc-gen-*` es `protoc`, que
lo publica la receta `protobuf`. Preguntar «¿existe recipes/protoc.toml?» habría seguido diciendo
«no» DESPUÉS de cerrarlo, y un guardián que grita cuando ya está arreglado se empieza a ignorar.
· **La tabla de drivers es explícita, no una heurística sobre guiones.** `git-cliff` es subcomando de
git; `gettext-tiny` no es subcomando de `gettext`. Adivinar por el guión da falsos positivos, y un
vigía con falsos positivos no se lee.
Y trae `--autoprueba` con los dos controles, porque un guardián que nunca falló no se sabe si sirve:
NEGATIVO (escondo `git`, que sí está ⇒ tiene que cantar `git-`) y POSITIVO (sin tocar nada, no debe
cantar sobre los drivers presentes). Los dos pasan.
⚠ Una trampa medida escribiéndolo, dentro del propio script: `glob('store/*-go')` casa por SUFIJO y
matchea `…-protoc-gen-go`, o sea que contestaba que `go` estaba sellado cuando no lo estaba. El
nombre de receta se saca con una regex ANCLADA sobre el basename.
|
||
|
|
61c33800c5 |
traducir: lector de OpenRC — el par que cierra el camino de gioser, y casi la mitad NO se puede leer
`openrc → arje` sin escribir ningún traductor de ese par: cuarto plugin, cuarto par. OpenRC es el init de gioser, la máquina que esta mudanza termina borrando. **El hecho que manda acá: un servicio de OpenRC es un PROGRAMA, no una declaración.** Un unit de systemd se lee; un script de OpenRC puede hacer cualquier cosa antes de arrancar nada. Medido sobre el `/etc/init.d` real: **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=` que valga — leer las variables y emitir tarjeta daría un servicio que arranca OTRA COSA. Regla al revés que en systemd: `start()` propia ⇒ SIN-TRADUCIR y NO se emite tarjeta. Los números del corpus real cierran solos: 121 − 1 (no es openrc-run) − 60 (start propia) − 1 (sin `command=`) = 59, y el lector emitió exactamente 59, con 59 ids únicos. Dos cosas que OpenRC obliga a ir a buscar fuera del script: `/etc/conf.d/<x>`, donde viven los argumentos de verdad (a diferencia del `EnvironmentFile=` de systemd, éste SÍ está en la máquina: se lee y se aplica), y `/etc/runlevels/`, que es lo único que dice si el servicio arranca solo. **Tres defectos que sólo aparecieron corriendo contra las 121 de verdad**, no sobre un ejemplo mío: - `name=` no es un identificador sino un rótulo humano: en gioser vale «Aura Backend», con espacio, y se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero. - Ids repetidos: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos y son scripts válidos; dos con el mismo nombre dan el MISMO id determinista ⇒ semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda. - La tarjeta decía `"desde": "systemd"` viniendo de OpenRC: el campo que existe para saber de dónde salió algo era justo el que mentía. Estaba cableado. Y el centro deja de filtrar la entrada por extensión: los servicios de OpenRC no tienen ninguna, y filtrar en el centro es que lo que no entra se pierda EN SILENCIO. Filtra el lector, que sabe, y lo anota — así un `.bak` en `/etc/init.d` se REPORTA en vez de desaparecer. Controles: dos corridas dan el fichero byte a byte idéntico; los tres pares previos sin regresión (`nginx → caddy` sigue dando `Valid configuration`, `systemd → arje` sigue emitiendo sus 3 tarjetas). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
a14b62436c |
atuq §6.7.bis: la barra lateral pregunta, y contesta un modelo de ESTA máquina
Inferencia REAL de punta a punta, en una jaula sin red: panel → fondo → host → `llama-server` (el llama-cpp del corpus) → respuesta. El servidor lo levanta el host en la primera pregunta —no hay servicio de IA en la imagen— y **se muere con el navegador**, al revés que el daemon del torrent (§6.9): un torrent tiene trabajo que sobrevive; medio giga de modelo en RAM, no. El puerto nativo vive en el FONDO y no en la página: si viviera en la página, cerrar la barra lateral se llevaría el host y el modelo cargado con él. `scripts/test-atuq-ia.py` mide tres cosas: que la respuesta salga del modelo de la imagen, que no venga VACÍA, y que al cerrarse el navegador queden cero `llama-server`. Control negativo: sin modelo aparece la causa y NO hay respuesta inventada. ⚠ Ese censo nació roto y del género que este repo colecciona: `pgrep -c` no existe en busybox y el `|| echo 0` convertía el error en un cero — o sea en un verde. Con un motor vivo a propósito el guardián pasaba igual. Ahora cuenta leyendo /proc y la rotura falla como debe. Y el PRIMER intento de romperlo tampoco rompía nada: el motor de mentira moría al hacer bind porque el socket estaba en /salida, compartido entre las dos corridas. Queda UNA decisión, no trabajo: qué modelo se pinea (tamaño de imagen y licencia). Hasta entonces la imagen no trae modelo y el panel lo dice con todas las letras. |
||
|
|
fefa92ec5a |
traducir: familia «servicio» — systemd → tarjeta de arje, y el pivote pasa a ser uno POR FAMILIA
Un unit de systemd no es un sitio web. Meterlo en `Sitio`/`Ruta` sería justo el «parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro empareja SÓLO dentro de la familia. Un par cruzado se rechaza con un error, no con un intento: `systemd → caddy` sale por `sys.exit`, porque traducir entre familias daría un fichero que PARECE correcto. Dos familias hoy: `web` (nginx, apache → caddy) y `servicio` (systemd → arje). 3 pares con 5 plugins. **Dónde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema.** El payload `Native` de arje acepta `exec`, `argv` y `envp` y nada más (comprobado en la semilla real del producto): NO hay campo de usuario. Un `User=git` traducido en silencio correría el servicio COMO ROOT — escalación de privilegios en un fichero generado que nadie vuelve a leer. Sale SIN-TRADUCIR con su línea. Igual `EnvironmentFile=` (el fichero no está en la máquina donde se traduce ⇒ envp incompleto, falla tarde), `Type=forking` (arje supervisa al proceso que lanza ⇒ BUCLE DE REINICIO) y todo el endurecimiento, que callado entrega un servicio MENOS confinado que el original. Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la semilla del producto — buscarlo antes de declararlo intraducible evitó una elisión inventada. **Lector y escritor no opinan del mismo campo.** `User=` lo CAPTURA el lector y lo JUZGA el escritor; cuando los dos anotaban, el acta decía dos cosas distintas de la misma línea, y un acta que se contradice se deja de leer. El lector registra en qué línea vio cada campo (`Servicio.lineas`) para que el escritor señale la línea real en vez de un 0. Controles: la tarjeta generada tiene el MISMO juego de claves que la tarjeta real de `sshd` del producto (+`_mudanza` de procedencia); dos corridas dan el fichero byte a byte idéntico; y la familia `web` sigue validando con el caddy del corpus (`Valid configuration` en los dos pares). De paso, `ulid_determinista` sale a `ids.py`: lo necesitaban `declarar.py` y el escritor de arje, y copiarlo habría dejado dos generadores de id que se pueden separar sin que nada falle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
3137d47f16 |
traducir: CENTRO de traducción con modelo pivote y plugins — N+M en vez de N×M
Traducir de a pares no escala: con nginx, apache, caddy, haproxy y lighttpd son 20 traductores, y cada formato nuevo agrega 2N. Con un modelo intermedio son N lectores + M escritores: un formato nuevo cuesta uno o dos plugins y estrena todos los pares de golpe. **Probado, no argumentado**: el lector de apache se agregó SIN TOCAR el escritor de Caddy, y con eso `apache → caddy` funciona sin que exista ningún traductor de ese par. Las dos salidas las valida el caddy del propio corpus: `Valid configuration` en las dos. Los plugins se DESCUBREN (`formatos/` + `@lector` / `@escritor`): una lista mantenida a mano se desincroniza y el formato nuevo «no existe» sin que nada falle. `--list` dice qué pares habilita. **El acta viaja DENTRO del modelo**, y es la decisión que hace que el pivote no mienta: si fuera un efecto de cada traductor, lo que el LECTOR no entendió se perdería antes de llegar al escritor. El escritor además puede agregarle — Caddy no tiene equivalente de `location ~`, así que en vez de emitir un `handle` de prefijo que SE PARECE, lo manda al acta. Parecerse es peor que faltar. Y un bug del lector de apache, encontrado probando: `ProxyPass` tiene dos formas y dentro de `<Location>` lleva sólo la URL. Mirar sólo la de tres tokens dejaba sin traducir la forma más común y el acta la reportaba como «no reconocida» — un falso negativo que manda a escribir a mano algo que el lector sí sabe hacer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
033913bb4a |
traducir.py: nginx → caddy, con acta — y validar con el binario real cazó un bug que CAMBIABA el sentido
Cambiar de nginx a caddy es reescribir la configuración. `traducir.py` hace la parte mecánica y dice
CON NÚMERO DE LÍNEA lo que no pudo traducir.
Doctrina heredada de `soltar`/`paskaq` (tawasuyu): su dominio es otro —datos presos en formatos
cautivos— pero sus principios son los que hacían falta. **Elisión honesta**: lo que no se pudo
traducir se reporta con su línea y su motivo, porque un traductor que descarta en silencio te deja un
servidor sin una redirección o sin una regla de auth y el sitio parece funcionar. **No adivinar en
silencio**: cada heurística queda como decisión explícita. **Procedencia**: cada bloque dice de qué
línea salió.
⚠ **Validar con el caddy real destapó dos bugs que leer la salida no mostraba:**
1. `ambiguous site definition` — en nginx dos `server` con el mismo nombre se distinguen por su
`listen`; en Caddy, por el esquema de la dirección.
2. **El grave**: un `if (...) { return 403; }` salía como `respond 403` INCONDICIONAL — el sitio
entero devolviendo 403. La directiva estaba dentro de un bloque declarado intraducible y se
absorbía igual al de afuera. Es peor que la pérdida silenciosa: no pierde, CAMBIA el sentido.
Ahora todo lo que vive en un bloque opaco sale `SIN-TRADUCIR` con su motivo.
Con los dos arreglados: `Valid configuration` según el caddy del propio corpus.
Y es honesto sobre su alcance: traduce el núcleo común y declara el resto. Uno que cubre el 70 % y
dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
68fdd9eb72 |
mudanza: el perfil — el software del servidor nuevo se DECLARA, no se instala a mano
`declarar.py --perfil-out` emite un `[perfil.<label>]` listo para `targets.toml`, generado desde el censo: cada raíz está ahí porque un servicio que CORRÍA la necesita y takana tiene receta. Por qué un perfil y no una lista de `install`: mudar servicio por servicio a una caja viva va contra el diseño —el software de una máquina se declara y viene en la imagen, reproducible y firmado— y un servidor armado a fuerza de instalaciones sueltas no se puede volver a construir. Que es justo el problema que esta mudanza existe para no repetir: el `caddy` de gioser no tiene dueño ni receta y se pierde con la máquina. Y hay un impedimento medido, no estético: `takana install` REPRODUCE desde fuente y eso exige el lab entero en el cliente (SDD 28 §5.4), que una caja de destino no tiene. El perfil lo resuelve por el lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab. Del censo de gioser salen `caddy`, `gitea`, `python3` (agrupado: lo piden python3 y uvicorn). Verificado que el instrumental lo acepta: `targets.py gioser-mudado` expande a 33 raíces. ⚠ Y dice lo que NO puede cubrir, uno por uno con su motivo: 34 servicios entre `suelto`, `paquete-ajeno` y `desconocido`. Un perfil que se calla lo que le falta sale N/N describiendo un servidor incompleto — la lección de `foot` en escritorio-sway. Con esto la mudanza produce TRES documentos declarativos que reconstruyen el servidor desde cero: el perfil (qué software), la semilla (qué corre y cómo) y el plan (qué datos, en qué orden). Ninguno es un log de lo hecho: los tres son entradas que se vuelven a ejecutar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
8ad9f0490d |
planear: alternativas por familia funcional, con una recomendación que no es gusto
Un servicio casi nunca es insustituible. `planear.py` agrupa por familia (servidor web, base de datos, caché, forja git, contenedores, dns, correo, base vectorial), muestra qué equivalentes tiene takana —marcando sellado / receta sin sellar / no está— y recomienda. Pero no «el mejor» en abstracto: un gusto disfrazado de dato es peor que no recomendar. Los criterios son hechos comprobables, en orden: 1. **El que ya corre, si takana lo construye** — porque cambiar de servidor web no es cambiar un binario: es REESCRIBIR la configuración entera, y eso casi siempre pesa más que cualquier ventaja teórica del otro. 2. Si el que corre no está en el catálogo pero un equivalente sí, se recomienda ése (takana puede construirlo, firmarlo y reproducirlo) diciendo lo que cuesta. 3. Si no hay ninguno, se dice. **No se sugiere «usá otro» cuando ese otro tampoco está.** Probado contra el catálogo real: `caddy` → se queda (ya corre y está sellado); `nginx` → recomienda caddy nombrando el costo; `redis` → «recomendado: NINGUNO», porque ni redis ni valkey ni memcached están en el corpus. ⚠ Y el dato que la recomendación destapa, que vale más que la recomendación: de servidores web el corpus sólo tiene **caddy y traefik**. No hay nginx ni apache. Si se elige un equivalente queda en el censo (`alternativa = "…"`) y el plan lo dice en su paso, con la advertencia arriba de todo: la configuración del origen no sirve tal cual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
6f5cc2629a |
mudanza: de dónde sale cada binario — y la herramienta corrigió un error mío
«Instalá el paquete» es lo que uno ya sabía. La respuesta útil sale de cruzar dos hechos: quién posee el fichero en el ORIGEN (el censo se lo pregunta al gestor de paquetes de esa máquina, en un lote) y si el corpus de takana tiene una receta con ese nombre. receta-takana → instalarlo del repo firmado, NO copiar el binario paquete-ajeno → escribir receta, o qorpa (ADR 0015) suelto → nadie lo provee: llevarlo con su entorno o escribirle receta Medido sobre gioser (37 servicios vivos): 4 receta-takana · 4 paquete-ajeno · **11 SUELTOS** · 7 sin binario legible. Los sueltos viven en `/usr/local/bin` o en un home (`qdrant`, `matilda`, `pacha-secretos`, `act_runner`, `shuma-gateway`) y son los que se pierden al apagar el origen. Sorpresa medida: `/usr/bin/caddy` tampoco tiene dueño — está puesto a mano. **Y corrigió un error mío**: en el SDD 28 escribí que `gitea` no tenía receta y que «es la que más peso tiene». Las dos cosas falsas — `recipes/gitea.toml` existe y está sellada. Lo afirmé de memoria; el programa fue a mirar. Corregido allá con la nota. **Y un fallo del clasificador, que vale como regla**: calculaba la raíz del repo con un `dirname` de menos, no encontraba ninguna receta y contestaba `suelto` A TODO, incluido `caddy`. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
808e4a4660 |
install-image: UNA imagen que arranca por BIOS **y** por UEFI
«Instalable en Hetzner o en cualquier otro servicio» se rompía en el arranque: Hetzner Cloud arranca
BIOS y otros proveedores sólo ofrecen UEFI. Tener dos imágenes hermanas obliga a elegir por proveedor
y a que diverjan — y ya divergían: la hermana EFI documenta etiquetas `takana-*` que su propio código
no escribe (corregido en este commit; son `hammer-*` y están congeladas a propósito por el ADR 0016,
porque viven en sistemas YA INSTALADOS).
Layout nuevo: `p1` BIOS-boot · **`p2` ESP FAT32** · `p3` `/` · `p4` estado · `p5` store (última, la
que crece). El `core.img` de i386-pc y el `BOOTX64.EFI` de x86_64-efi apuntan **los dos** a
`(hd0,gpt3)/boot/grub`: **un solo `grub.cfg`, una sola línea de comando, un solo sitio donde
editarla.** La ESP se puebla con mtools, sin root y sin loop, como el resto del script.
No se usa EFI-stub directo acá (sí `install-image-efi.sh`, ADR 0010): el stub por la ruta fallback
recibe LoadOptions VACÍO y necesita la cmdline HORNEADA en el kernel; la de `linux-generic` sólo trae
la consola, sin `root=`. Hornearla ataría la línea de comando al ArtifactHash del kernel.
Verificado con LA MISMA imagen en los dos firmwares, hasta entrar por SSH:
UEFI (OVMF) → /sys/firmware/efi presente · PID1 arje-zero · raíz sda3 · store sda5
BIOS (SeaBIOS)→ /sys/firmware/efi ausente · PID1 arje-zero · raíz sda3 · store sda5
Si falta `grub-mkimage` con x86_64-efi o mtools, la ESP se saltea con un aviso que dice qué se pierde
—no en silencio—: la imagen sigue arrancando por BIOS, pero eso la ata a esos proveedores.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
96dc488842 |
mudanza: recomendaciones CON MOTIVO, y la lista completa a la vista para elegir
Correr en el origen no significa que aplique en el destino. `planear.py --revisar` muestra todo agrupado con su recomendación **y su razón**, y `--decide` deja aceptarlas en bloque o revisarlas una por una. Cuatro familias que no aplican en una caja remota, cada una con su motivo: · hardware local → bluetoothd, ModemManager, upowerd, adb · escritorio o pantalla → waypipe · la red del destino → NetworkManager, dhcpcd: **pelearían** con el init de allá · lo provee el init destino → udevd, dbus-daemon, elogind, polkitd, agetty 11 de 37 servicios de gioser caen ahí. El motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a ciegas o se ignora entera. **Y la recomendación DERIVADA, que es la más fuerte**: si el binario vive en un árbol que no se muda, el servicio no podría arrancar allá. Se calcula cruzando el `cmdline` leído de `/proc` contra las decisiones de datos. Probado en los dos sentidos: con `/mnt/vvv` muriendo, `puerta-f6e393ff` sale «no mudar — su binario vive en /mnt/vvv»; con `/mnt/vvv` mudándose, sale «mudar». Por eso la revisión decide los DATOS PRIMERO: de ellos se deriva la recomendación de los servicios, y al revés no se puede calcular. Y los datos salen SIN recomendación a propósito — qué datos valen no se deduce de la máquina; `terapeuta.ec` eran 279 M sin DNS y sólo el usuario podía decidirlo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
d682b9b240 |
mudanza: el APLICADOR — idempotente, reanudable, y que no marca como hecho lo que no ejecutó
`aplicar.py` ejecuta un plan. La regla que lo define: **un paso que no se ejecutó no se marca como hecho**. Un plan tiene pasos ejecutables y pasos MANUALES cuyo `cmd` son comentarios («instalá el paquete», «cambiá el registro A»); un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok» — la peor mentira posible, porque deja el servicio caído con el informe en verde. Acá quedan `pendiente-humano`, la corrida sale con ≠0, y `--hecho N` los confirma — negándose si el paso sí tenía comandos («corrélo, no lo marques»). Se le cree a la VERIFICACIÓN, no al exit code: el rsync que llenó el disco devolvió 0 y dejó 1367 artefactos vacíos. Si el comando sale bien y la verificación falla, queda `sospechoso`. Y las verificaciones van TIPADAS (`cmd`/`humano`): «la columna Available debe ser > X» no es un comando. Estado reanudable en `<plan>.estado.json`, escrito con temporal + fsync + rename — la lección que costó un upgrade entero en el SDD 28. **Y un fallo propio, encontrado en la primera corrida real**: la verificación del paso de datos imprimía el número de directorios vacíos y devolvía 0 igual. Dio «✓ verificado: 2» donde ese 2 eran dos vacíos en destino. Un guardián que siempre pasa no es un guardián. Ahora COMPARA los ficheros de los dos lados y falla si difieren. Probado con rotura a propósito y con el control que debe pasar: copia no hecha ⇒ origen=5 destino=0 ⇒ FALLA; copia hecha ⇒ 5 y 5 ⇒ PASA. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
1a89592b72 |
mudanza etapa 3: declarar.py — de «corre y nadie sabe cómo» a una Semilla que arranca
Copiar un binario no lo levanta al arrancar. Esto toma la invocación que el censo leyó de `/proc` y emite `seed.card.json`. Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos**, y la tarjeta de caddy sale con su invocación real — exactamente lo que faltaba para que no muriera en el próximo reinicio. Por qué no alcanza con `arje-absorb`: absorb lee la DECLARACIÓN del init ajeno, que es justo la que miente (`rc-status` daba `stopped` para cinco servicios vivos), y los `no-declarado` no aparecen en ninguna declaración por definición. Absorber la declaración reproduce el agujero. Se complementan. Tres reglas: 1. Un servicio sin `cmdline` legible NO se emite y se dice: una tarjeta que no arranca es peor que una ausente — la ausente falla ruidosamente, la rota deja el servicio caído en silencio. 2. El `cwd` se preserva ENVOLVIENDO, porque el payload `Native` acepta `exec`/`argv`/`envp` y no `cwd` (comprobado sobre la semilla real). 9 de 37 servicios de gioser dependen de su directorio. 3. IDs deterministas ⇒ misma entrada, misma semilla BYTE A BYTE (verificado con sha256). Un fichero generado que cambia en cada corrida no se puede revisar con `diff`. **Y un bug del censo que costaba el 70 % del dato**: `cmdline` y `cwd` se leían en un solo comando, y como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el comando entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`. 26 de 37 servicios quedaban sin invocación. Sondas separadas. **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.** Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
6f12c231fa |
mudanza etapa 2: el PLAN — exportable, con el comando literal de cada paso
`planear.py` convierte un censo decidido en un plan ejecutable. Cada paso lleva su COMANDO LITERAL y
su verificación, así que el fichero se ejecuta a mano, línea por línea, sin la herramienta y sin este
repo. Eso es lo que el usuario pidió como «pasos exportables».
Las tres reglas, cada una pagada en el SDD 28:
1. **Nada sin decidir se ejecuta**: aborta con código 2 listando qué falta. El silencio no es
consentimiento — sin decisión, ni mudar ni matar es correcto. Probado en los dos sentidos.
2. **Lo que muere se dice por su nombre y con su tamaño ANTES de borrar**, y el paso no borra nada:
es una lista para leer antes de apagar el origen.
3. **Cada copia se verifica EN DESTINO**: el `rsync` que llenó el disco devolvió 0 y dejó 1367
artefactos vacíos; sólo se vio contando del otro lado.
Y el preflight dimensiona con el tamaño de COPIA (hardlinks expandidos, 60 G → 85 G medidos), no con
`du`; si un tamaño resulta ilegible lo dice en vez de contarlo como 0.
**El añadido que cambia el valor: la invocación real.** Decir «este servicio no está declarado»
nombra el problema; lo que hace falta para resolverlo es cómo corre AHORA. El censo lo lee de
`/proc` (`cmdline` + `cwd`, nunca `environ`: el entorno trae tokens) y el plan lo emite:
# cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
# cwd : /mnt/vvv/tawasuyu/shared/tejido
# puertos: 34221, 44961
Ese servicio de gioser es un binario de `target/debug/deps/` corriendo en producción, con dos
puertos, que ninguna declaración conoce. Apagada la máquina vieja, eso no se reconstruye de memoria.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
5d1120e583 |
censar: la sonda de dominios dejó a gioser incomunicado — «sólo lee» era falso
Censar gioser en `--local` disparó 29 peticiones HTTPS a sus propios dominios. Varios devuelven 502, y su `fail2ban` (jaula `caddy-backend-down`) **baneó la propia IP de la máquina**: gioser dejó de poder hablar con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote repository». Un censo que deja la máquina incomunicada no es de sólo lectura. **La frase del script era la mentira**: «sólo lecturas, no se toca nada». No escribe nada, cierto — pero leer POR RED tiene efectos. Corregida. Tres arreglos: 1. **La sonda no se hace contra uno mismo.** Por defecto sólo con `--host`; en `--local` se saltea y se avisa por qué, con `--probe` para forzarlo. Además es metodológicamente mejor: lo que interesa es qué ve EL MUNDO, no qué ve la máquina de sí misma. 2. **Espaciada** medio segundo entre dominios, para no parecerle un escaneo al fail2ban del objetivo. 3. Clase nueva `sin-sondear`, para que un dominio sin datos no se confunda con un fósil. Desbaneado 204.168.193.248 de `caddy-backend-down`. Y queda la regla: **antes de sondear una máquina, pensar qué defensa propia le estás disparando.** Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
95c16097e4 |
SDD 29 + censar.py: la mudanza como PRODUCTO — censo, plan, aplicación. Sin IA.
Corrección de rumbo pedida por el usuario: el SDD 28 derivó hacia «poner ESTA caja a punto» — instalar caddy, copiar claves, montar discos—, y eso es configurar un servidor, no construir algo. **caddy no es un hueco de la imagen: es una instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.** Y la consecuencia de método: todo lo que hice a mano en el SDD 28 ES la especificación de este programa. Cada paso fue determinista; el único juicio fue «¿esto se muda o muere?», que es justo lo que se le pregunta al usuario. No hace falta IA: hace falta que esté escrito. **Etapa 1 implementada: `scripts/mudanza/censar.py`.** Sólo lee. Cruza lo DECLARADO contra lo VIVO y reporta tres clases, cada una justificada por un error medido: - `rc-status` decía `stopped` de cinco servicios que estaban VIVOS ⇒ la verdad es `ppid==1`, no el init. - De 29 dominios, 10 vivos: 13 sin DNS, 2 en 502 y **4 que ya resuelven a otra máquina** — por eso se compara la IP del DNS contra las de la máquina, o `ya-mudado` se lee como `vivo`. - `du -sh` no dice cuánto ocupa COPIAR con hardlinks (60 G vs 85 G): se reportan los dos. - Los nombres no coinciden (`act-runner`/`act_runner`, `crond`/`cronie`, `dbus-daemon`/`dbus`): sin normalizar, el mismo servicio sale a la vez como `no-declarado` y `declarado-muerto`. - Lo descartado se CUENTA: un `ppid==1` que no es servicio suele ser un huérfano, y eso es hallazgo. Probado contra gioser, donde las respuestas ya se sabían a mano: encuentra MÁS (29 dominios contra los 19 que probé; apareció `hifas.gioser.net`). Y afinarlo importó: la primera versión daba 28 `no-declarado` con ruido, la segunda da 18 y son reales (`matilda`, `pacha`, `puerta-…`, `adb`). Un guardián con hallazgos falsos se ignora entero. El SDD deja escritas las etapas 2 (plan exportable: el fichero ES la interfaz) y 3 (aplicación idempotente que verifica EN DESTINO y deja el server corriendo), y mide qué ata la imagen a Hetzner: menos de lo que parece — BIOS vs UEFI y los metadatos de red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
77cda9a865 |
install-image: un filesystem etiquetado hammer-work se monta en /work
Convención opcional, no requisito: la imagen no crea esa partición y si la etiqueta no existe el arranque no hace nada ni avisa. Existe por un caso concreto. Al mudar el store de la caja de producción al volumen, la partición local del store —69,8 G de un disco de 76,3— quedó huérfana: re-etiquetada para que el `findfs` del arranque no la confundiera con el volumen, y sin montar. La caja tenía 70 G de disco ocioso mientras `/store` iba al 80 %. `/work` es el destino natural: es donde un HUB pone lo pesado —`work/sources`, los tarballs, el `CARGO_HOME`—, que en gioser son 85 G y que no cabe ni conviene en una raíz de 6 G. En la caja: `sda4` re-etiquetada `hammer-work`, REFORMATEADA (traía los 61,5 G del store viejo, anterior a la cosecha; control antes de borrar: el store vivo tiene 1579 artefactos y el respaldo 3140) y `/opt/takana/work` pasa a ser un enlace a `/work`. Verificado tras reiniciar: se monta solo, 68,1 G con 64,6 G libres, y los manifiestos siguen donde `build-state.py` los busca. La caja usa ahora su disco entero: `/` 5,8 G · `/var/lib/hammer` 487 M · `/work` 68,1 G local · `/store` 97,9 G en el volumen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
d1d6645bd0 |
respaldo: una guarda que evita BORRAR el historial de git del Storage Box
Encontrado corriendo el respaldo desde la caja de producción (SDD 28, puerta 7). El paso [2/3] sube el repo **con `--delete`** —correcto: para eso está git—. Pero el `/opt/takana` de la caja llegó por `rsync --exclude=.git`: es una COPIA, no un clon. Una corrida real desde ahí no habría subido menos cosas: **habría borrado del respaldo todo lo que le falta al origen, empezando por `.git`** — el historial entero. Y en silencio, porque rsync haría exactamente lo que se le pidió. Ahora falla ruidosamente si la raíz no tiene `.git`, con `REPO_INCOMPLETO=1` como escotilla para el caso deliberado. Probado en los tres sentidos: en gioser (con `.git`) pasa; en un árbol sin `.git` aborta; con la escotilla pasa igual. **Y el store tampoco estaba donde el script creía.** Usaba `$RAIZ/store` cableado — en gioser es un bind-mount dentro del repo, pero en una caja takana instalada es una partición en `/store`, así que el paso [3/3] moría con `change_dir "/opt/takana/store" failed`. Peor: rsync devuelve 23, que está en la lista de reintentables, así que el bucle lo reintentaba — exactamente el cuadro que la cabecera de este script ya documenta («un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»). Ahora `STORE` es env y hay una guarda ANTES del bucle que aborta si no existe. Con eso el `--seco` completa los tres pasos desde la caja y lee la ocupación del box (111 G de 1 T). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
4190c7b43e |
atuq §6.7: entra el motor de inferencia local — y SOURCE_DATE_EPOCH le había apagado el AVX2
La IA local de la barra lateral (§6.7) y la mitad semántica del archivo personal (§6.3) figuraban
como dos pendientes distintos. Son uno: el corpus no tenía con qué correr un modelo. `pluma-llm`
sólo trae backends de NUBE y `rimay-verbo-fastembed` DESCARGA onnxruntime (glibc) y el modelo de
HuggingFace en el primer arranque. `recipes/llama-cpp.toml` (b10901, estática, 199 M) derriba el
muro entero: el mismo binario sirve `/v1/chat/completions` y `/v1/embeddings`.
⚠ Lo que este commit deja medido, y es lo que no se podía deducir: el PRIMER sello llevaba sólo
`-DGGML_NATIVE=OFF` —leído en `ggml/CMakeLists.txt:141`, que con NATIVE=OFF debería ENCENDER las
perillas explícitas de ISA— y salió con CERO instrucciones vectoriales. La causa está 36 líneas
más arriba: `if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})` apaga
`GGML_NATIVE_DEFAULT`, y el sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`)
justamente para que los builds REPRODUZCAN. O sea: la variable que nos da reproducibilidad apagaba
todas las instrucciones vectoriales del motor de inferencia — y no falló nada. El artefacto selló,
`--version` contestaba, y el binario era x86-64 pelado: 0 `%ymm`, 0 `vfmadd`, 0 `roundps` en
1.634.770 líneas de `objdump -d`. Ahora las seis van declaradas una por una y el `install` LAS
COMPRUEBA en el binario: 42.356 `%ymm` / 1.298 `vfmadd` / 86 `roundps`.
`strip_debug = true` porque zig cc emite debug_info por defecto y son 16 binarios estáticos:
1,8 G → 199 M.
Guardián `scripts/test-llama-cpp.py`, sobre el artefacto VIGENTE (resuelto por `takana hash`, no
por `ls store/*`) y con control negativo vivo (`--sin-modelo`, que TIENE que fallar): ISA,
hermético (0 NEEDED), una pasada de inferencia REAL con `stories260K` pineado por sha256, y el
endpoint de embeddings — vector de verdad, el mismo texto da el mismo vector, dos textos distintos
dan vectores distintos, y no son ceros. Y `verificar-repro.sh`: REPRODUCE, 0 no-determinismos.
⚠ Esto es el MOTOR, no la función. Un motor sin modelo no contesta nada: el modelo de producción
es fuente pineada aparte, como el perfil de PGO de firefox, y es su propia unidad de trabajo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
|
||
|
|
01b1dcfefe |
respaldo: el rsync del propio corpus no podía correr el respaldo del propio corpus
Corriendo el respaldo desde la caja de producción (SDD 28, puerta 7):
==> [1/3] estado (el grafo: qué había construido y con qué hash)
unknown compress name: zstd
!! estado: error 4 que NO es de red — no reintento
El script exige `--compress-choice=zstd` (medido: el doble de rendimiento efectivo, porque el 79 %
del store son secciones `.debug_*` y comprimen como texto) y **`recipes/rsync.toml` lo construía con
`--disable-zstd`**. El comentario de la receta decía por qué: «deps externas quitadas (no en
catálogo)». Ya no es cierto — `zstd` tiene receta y está sellada.
Dos arreglos, y los dos hacen falta:
1. **La receta enciende zstd** (dep `zstd`, fuera el `--disable-zstd`). Control en los dos sentidos:
el rsync nuevo lista `zstd zlibx zlib none`, el anterior `zlibx zlib none`. Radio cero: `rsync` no
es dep de ninguna receta (medido), así que no re-hashea nada más.
2. **El script DEGRADA en vez de morir.** Detecta el soporte (`rsync --version` → «Compress list»)
y cae a zlib avisando. Un respaldo que no corre por un algoritmo de compresión es peor que un
respaldo lento — y el fallo era especialmente malo porque el error 4 se clasifica como "no de red"
y el script no reintenta: el respaldo simplemente no se hace.
Y de paso queda cubierto el caso de un hub que todavía no reconstruyó su rsync.
**También la raíz cableada**: el script hacía `cd /mnt/vvv/takana` por defecto —la ruta de gioser—
así que desde cualquier otro hub moría con `No such file or directory`. Ahora se deriva de la
ubicación del script, como el resto de `scripts/`; el override por `RAIZ` se conserva. Control: en
gioser sigue resolviendo a `/mnt/vvv/takana`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
10c499777e |
atuq §6.5.bis: la extensión foco — muestra el foco del sistema y no tiene con qué apagarlo
La mitad del navegador del §6.5, deliberadamente asimétrica: sólo lee. Pregunta `focus.state` (nuevo en puriy-costura, commit 38815b5f3) y pinta tres estados — `foco`, nada, y `?` cuando nadie escribió el estado. Ese tercero es el que importa: si «no sé» se redondeara a «apagado», la insignia afirmaría que no hay foco sin haberlo mirado. No hay verbo para apagarlo, y es la propiedad y no un pendiente: si el navegador pudiera levantar el foco, valdría lo que vale un bloqueador de extensión. En tawasuyu hay un test que lo fija; acá el guardián mide el EFECTO — tras una sesión entera, el fichero de estado quedó igual. `scripts/test-atuq-foco.py` corre los tres estados sobre el path de PRODUCCIÓN (`/etc/takana/focus`), no la escotilla de pruebas: una escotilla mide el código, no el contrato con la imagen. Verde sobre el artefacto vigente (atuq 5d1afc50, puriy-costura 0de6b4ca), y verificado rompiéndolo — con una extensión parcheada que pinta «foco» siempre (en una COPIA del artefacto, vía ATUQ_DIR), falla nombrando la insignia. |
||
|
|
63b1c42cc9 |
install-image: el store va ÚLTIMO y CRECE al disco entero en el primer arranque
Una imagen se escribe con `dd` sobre un disco casi siempre más grande que ella. La del perfil servidor son 7 G; la caja hcloud tiene 76,3 G. **Sobraban 69 G que nadie podía usar**, y la caja arrancaba perfecta con el disco a un décimo — el fallo que no falla, otra vez. Dos cambios que van juntos: 1. **El store pasa a ser la ÚLTIMA partición** (p1 bios, p2 `/`, p3 `/var/lib/hammer`, p4 `/store`). Sólo la última puede extenderse sin mover datos, y el store es justamente la que crece con el uso. Con él en el medio, la partición extensible era la de ESTADO, de 512 M, que no le sirve a nadie. El cambio es seguro porque nada referencia números de partición: `root=PARTLABEL=hammer-root` y el wrapper monta por etiqueta con `findfs`. 2. **El wrapper de `/sbin/init` la extiende en el primer arranque**, antes de montarla: `sfdisk -N <n> ', +'` → `partx -u` → `e2fsck -pf` → `resize2fs`. Idempotente: si ya llega al final, los dos últimos no hacen nada. Guardado tras `-x /sbin/sfdisk` para no romper un rootfs que no lo traiga, y el aviso va a `/dev/kmsg`, que es donde se lee. Probado en QEMU volcando la imagen de 7 G en un disco de 20 G: el arranque imprime `init: store: /dev/sda4 extendido al final de /dev/sda` y `/store` queda en **13,3 G** con 12,6 G libres, sin tocar nada a mano. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
212b304b60 |
atuq §6.5: el foco por cgroup MEDIDO — y corregido el propio documento
La fila de la tabla se leía como «bloquear sitios». El cortafuegos NO sabe de sitios: su política de egress tiene UNA dimensión, qué cgroup sale, y ninguna de destino (leído en UnidadRed, no supuesto). El foco que estas piezas dan es «el navegador no sale», con todo lo local andando — promesa más honesta, además: una lista de dominios se esquiva con un espejo; un cgroup sin egress no. Medido con nuestro propio nft, en el LXC donde somos root: linea-base exit=0 · control exit=0 · foco exit=1 Tres medidas y no una: la línea base porque un harness roto se lee igual que un foco que funciona, y el control porque un reglaset que niega de más tampoco se distingue. `--broken-rules` abre egress al cgroup en foco y el guardián falla nombrándolo (sale 1). Verificado en los dos sentidos. Restricciones reales que salieron de medir: crear un cgroup pide root (EPERM incluso en un userns con CAP_ALL), y `nft -c` no es sintaxis — resuelve el path del cgroup contra la máquina viva. ⚠ Y lo que rompí en el camino, documentado: `/sys/fs/cgroup` está montado SHARED, así que desmontar la copia de un --rbind se propaga al montaje real. Dejé al worker sin cgroup2 dos veces, en silencio. Remontado y jerarquía intacta. Ahora todo va en un mount namespace propio (--propagation private), sin `umount -l` antes de un rm -rf, y con /proc/mounts consultado antes de borrar. Tres falsos positivos más que cazó el harness, todos con cara de éxito: un gmp viejo tomado por `ls store/*-gmp` (sin .so ⇒ nada conectaba, y el lab del hub lo tapaba), /sbin fuera del PATH del chroot (sin `ip`, loopback caído), y un COMENTARIO que se ejecutó — backticks dentro de un heredoc sin comillas corrieron `ip`/`ifconfig` en el host y pegaron su salida en el guión generado. |
||
|
|
c0545ea40c |
repo: clave de release ESTABLE y un publicador por perfil — se acabó firmar con una clave efímera
`build-repo.sh` generaba una clave nueva en cada corrida si no se le pasaba `KEY=`. Un índice firmado
con una clave que nadie conoce y que cambia cada vez no lo verifica nadie: es decoración. El SDD 19
§3.2 lo dice sin vueltas — la firma sin gestión de claves es teatro.
- `trust/release.ed25519.pub` — la clave PÚBLICA, en el repo, que es donde tiene que estar para que
un cliente pueda verificar. La privada vive en `~/.config/takana/keys/release.ed25519` (0600),
fuera del repo, mismo trato que las credenciales del Storage Box.
- `trust/README.md` dice también **lo que esto NO es**: no hay clave raíz fuera de línea, ni
rotación, ni procedimiento de filtración. Cierra el agujero de la clave efímera, NO el §3.2.
- `scripts/repo-perfil.sh` publica la clausura de un perfil y **aborta si no encuentra la clave**, en
vez de inventar una. El conjunto sale de `yupana.membresia()` sobre `targets.toml`, la misma fuente
que usa la imagen ⇒ el repo y la imagen no pueden divergir: son la misma lista.
- Y trae su propia guarda: tras firmar, VERIFICA el índice contra `trust/` y falla si no ancla.
Medido: 88/88 del perfil `servidor` con `expected_hash` anclado, índice firmado que verifica.
Control en los dos sentidos contra el repo servido por la caja:
--trust ./trust => "release: trusted (by release)" ⇒ instala
--trust <dir vacío> => "release: unknown-key ... clave no confiada" ⇒ ABORTA
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
b98c46b530 |
atuq §6.9: el guardián del torrent dejaba un daemon suelto y decía ✓
Encontrado con `ps` sobre la máquina, no por un test: al cerrar el sandbox quedaban vivos el daemon y su `bwrap` interno, sosteniendo overlays ya borrados. Es correcto que el daemon se quede —su torrent de prueba no tiene enjambre, así que nunca termina y nunca está «sin nada»—, lo que faltaba era despedirlo y MEDIR que se fue. - el guión de adentro le pide `puriy-costura-torrent stop` (el verbo del producto); - la huella del socket se anota adentro y ANTES del stop: el daemon lo borra al irse; - el guardián censa daemons antes/después con `ps -C` (nunca `pkill -f`) y falla nombrando el PID; el `finally` barre lo propio para que un test rojo no deje basura; - tope de 180 s al sandbox, como seguro contra un cuelgue. Comprobado en los dos sentidos con una copia rota fuera del repo: sin el `stop` el guardián daba ✓ igual, y con la aserción nueva sale ✗ nombrando el PID. La hipótesis de que se COLGARÍA era falsa: el `bwrap` externo vuelve; el que espera es el interno. Verde: positivo, control negativo y rotura a propósito. |
||
|
|
c214438f00 |
atuq: el torrent lo toma un daemon propio, perezoso, que sobrevive al navegador
El 6.9 del SDD 26, con la arquitectura que pidió el operador: daemon PROPIO,
CONFIGURABLE y PEREZOSO. Vivaldi ya trae torrent y termina en una carpeta; acá lo que
baja entra al CAS —un objeto BLAKE3 con la misma identidad que una descarga del
navegador (§6.2) o un `.swm`—, y ésa es la razón por la que esto vale.
página de la extensión → fondo → connectNative → puriy-costura
→ /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está)
→ daemon: librqbit, y al completarse, el CAS
POR QUÉ NO ES UN VERBO MÁS DEL HOST — dos razones, y la primera decide:
1. una descarga tiene que sobrevivir al navegador, y Gecko mata al host cuando se
cierra el puerto (medido hoy);
2. librqbit + tokio + rustls son 226 crates: dentro del host, ese binario —952 K,
compartido por CINCO extensiones— pasaría a ~20 MB y cada iteración de cualquier
función del navegador a un cuarto de hora.
⚠ Que esa pila COMPILA para musl con zig-cc se midió ANTES de decidir, con una receta
desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus
había construido tokio+rustls desde tawasuyu— así que la decisión no fue «no se
puede», fue «no ahí». El artefacto del daemon pesa 7,7 M.
PEREZOSO EN LOS DOS SENTIDOS: no hay servicio en la imagen (el binario está y no corre
hasta que hay un torrent; lo levanta su cliente con `setsid`, sin lo cual moriría con
el navegador) y se va solo tras `inactividad_seg` sin nada activo — sembrar cuenta como
actividad.
CONFIGURABLE: TOML opcional; sin fichero anda, y uno ROTO es error. El CAS por defecto
es el del host, con un test que falla si esas raíces se separan.
LO QUE EL GUARDIÁN NO HACE, Y POR QUÉ (está en su cabecera y en el §6.9):
· no usa un `magnet:` sino un `.torrent` servido por HTTP —dar de alta un magnet
BLOQUEA esperando metadata que sin peers no llega: se mediría un timeout y no la
cadena—. El `.torrent` se arma en el propio guardián, en bencode a mano, porque un
binario de prueba en el repo es una dependencia que nadie revisa;
· no pasa por el despacho de protocolos de Gecko: un clic en `magnet:` abre el diálogo
de «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del
usuario y no código nuestro. Se abre la página de la extensión —la misma que el
handler abriría— descubriendo su URL base del `dump` de una primera corrida, porque
el UUID lo asigna Gecko por perfil y adivinarlo sería inventar.
El control negativo saca el cliente del daemon de la imagen y exige que el host lo diga
NOMBRANDO lo que falta, en vez de fingir que lo tomó.
⚠ Y sigue sin haber test automático de un transfer REAL entre peers: haría falta un
sembrador y una espera que volverían la suite una que nadie corre. Dicho en el test del
daemon y en el §6.9, para que nadie lo lea como «probado de punta a punta».
La página de la extensión NO valida la forma del origen: la valida el host, que es
quien decide qué le pasa al daemon. Tener esa regla escrita dos veces es tenerla
mintiendo el día que una cambie.
Del §6 quedan el foco (6.5) y la IA local (6.7), ésta bloqueada por algo medido: el
corpus no tiene ninguna receta de LLM ni de embeddings.
MEDIDO sobre `atuq b3:d1a444ad`, `puriy-costura b3:cad5c855` y
`puriy-costura-torrent b3:76afb7a8` (7,8 M):
MAGNET http://…/prueba.torrent
TOMADO prueba-atuq.bin · nuevo · en /salida/hogar/Downloads · id=0
socket del daemon: run/puriy-costura-torrent.sock ← nadie lo arrancó
⚠ Y DOS COSAS QUE EL GUARDIÁN DESTAPÓ, las dos por comprobar el NOMBRE y no sólo que
la respuesta llegara:
1. **la extensión leía claves que el daemon no manda** (`name`/`files`/`bytes` contra
`nombre`/`carpeta`), y el síntoma era «TOMADO — 0 fichero(s), 0 bytes»: parecía un
torrent vacío y era un vocabulario inventado del lado del lector — la misma trampa
que había evitado en el host y no acá;
2. mirándolo apareció que **el socket y la config del daemon estaban en castellano**, y
la regla 7 de tawasuyu nombra explícitamente las claves de configuración entre lo
que se tipea. Corregido allá antes de que llegara a ninguna imagen: un protocolo y
un fichero de config son contrato, y renombrarlos después rompe lo que alguien ya
escribió.
tawasuyu: 4f36f057 (el crate) · 3f69aab2 (lock) · 3b56db26 (el verbo del host) ·
066b3761 (el log del daemon, que faltaba y hacía invisible su único fallo de arranque)
· ca9947b9 (las claves en inglés).
|
||
|
|
2613fe3951 |
servidor-image.sh: la imagen del perfil servidor, armada por script — y netup pasa a ser raíz
El ensamblado dejaba de ser reproducible en cuanto se cerraba la terminal: cuatro pasos, tres de ellos con una trampa que no se ve. Ahora es un script, con las trampas escritas en su cabecera: 1. **El kernel.** `linux.toml` NO sirve para hcloud (apaga SCSI y el disco de Hetzner es virtio-SCSI). Va `linux-generic`, que sí trae `CONFIG_SCSI_VIRTIO=y` — dato que no está en la receta, lo pone `make defconfig`, y sólo se ve en el `.config` que el artefacto publica. 2. **El init se pisa.** La clausura del perfil trae busybox y su `/sbin/init` gana por hidratarse después. El script lo restaura e IMPRIME la reparación (`../bin/busybox → /usr/bin/arje-zero`), que es la diferencia entre arreglarlo y creer que estaba bien. 3. **EXDEV.** Comprueba con `findmnt` que STORE y WORKDIR estén en el MISMO montaje y aborta con un mensaje que dice qué hacer, en vez de fallar fichero por fichero a mitad de un `cp -al`. 4. **La clave es obligatoria.** Sin `AUTHKEYS` no arma nada: una instalación remota sin `authorized_keys` deja una máquina viva e INALCANZABLE, que es peor que una que no arrancó. Y el cmdline va SIN `init=`, a propósito: así corre el wrapper que escribe `install-image.sh`, que es quien monta `/store` y `/var/lib/hammer`. **`netup` entra como raíz de `perfil.servidor`** aunque su binario ya venga dentro del `product-rootfs`. La razón es concreta y se pagó hoy: el del producto está congelado en el artefacto sellado del bootstrap, así que el arreglo de la ruta on-link NO llegaba a la imagen. Declarado como raíz, la hidratación lo proyecta encima y la imagen lleva el vigente — verificado por sha256: la imagen trae `a23df20e…` y el product-rootfs `a84b0a0d…`. `recipes/netup.toml` re-pineado a `be383ea4` (el commit del arreglo). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
5c26a76ba0 |
install-image: el wrapper PID1 montaba /store y el diario en vda CABLEADO — en hcloud es sda
El wrapper que `install-image.sh` escribe como `/sbin/init` monta las dos particiones dedicadas antes de hacer `exec` del init real, y las nombraba `/dev/vda3` y `/dev/vda4`. Eso vale sólo donde el disco es virtio-blk. **En una caja Hetzner Cloud la controladora es virtio-SCSI y el disco es `sda`** (medido: el driver de `sda` es `sd` y `virtio_scsi` está cargado), así que los dos montajes fallaban. Y fallaban del peor modo posible: **el sistema arranca igual**. Sólo que `hammerd` —que la semilla de arje lanza con `--store /store --journal /var/lib/hammer/journal`— queda sin store y sin diario, con dos líneas de aviso perdidas en el arranque. Es exactamente la regla 3 de CLAUDE.md: un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien. Ahora el disco se DERIVA de dónde está montada la raíz (`/proc/mounts`), que es la única fuente que no depende ni del nombre del dispositivo ni de udev. Probado en los dos sentidos con /proc/mounts sintéticos: `/dev/sda2` → `/dev/sda3`, `/dev/vda2` → `/dev/vda3`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
c809a1786b |
install-image: la guarda del init estaba INVERTIDA, y el staging no podía cruzar el montaje
Dos cosas encontradas armando la primera imagen del `perfil.servidor` (SDD 28 §1b). **1. `[ -e "$ROOTFS/sbin/init" ]` rechazaba el rootfs CORRECTO y aceptaba el equivocado.** `-e` sigue el symlink y lo resuelve contra el HOST. El `/sbin/init` del product-rootfs es un symlink ABSOLUTO a `/usr/bin/arje-zero`, que en el host no existe ⇒ la guarda daba falso. Y al revés: un rootfs cuyo init es `../bin/busybox` —symlink RELATIVO, que sí resuelve dentro del árbol— la pasaba sin chistar. O sea que la única guarda que separaba «esto arranca takana» de «esto arranca otra cosa» estaba exactamente al revés. Ahora acepta `-L`, y se probó en los dos sentidos: acepta el symlink absoluto a arje-zero, sigue rechazando un directorio sin init. Esto no es teórico: hidratar `perfil.servidor` sobre el product-rootfs **pisa `/sbin/init`** — busybox entra en la clausura y su symlink gana por llegar después—. La imagen habría arrancado perfecto con el init de busybox en vez de arje-zero, que es peor que no arrancar. Se restaura el symlink al ensamblar y el cmdline lleva `init=/usr/bin/arje-zero` explícito, por las dos puntas. **2. `STAGE` estaba cableado a `work/.install-stage`.** El staging HARDLINKEA desde `$ROOTFS`, y un hardlink no cruza un montaje aunque sea el mismo disco. Con el layout normal de este repo —`store` y `work/out` son bind-mounts de /dev/sdb, `work/` está en /dev/sdc— el rootfs vive del otro lado y `cp -al` muere con EXDEV fichero por fichero. Ahora es env, documentado en la cabecera. De paso: el fichero no tenía bit de ejecución (100644) mientras sus siete hermanos son 100755, así que su propio ejemplo de uso `./scripts/install-image.sh` no funcionaba. Venía de antes; corregido. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x |
||
|
|
928d5eb119 |
atuq: un vídeo se abre en el reproductor de la distro, no en una pestaña
El 6.6 de la unidad 9 del SDD 26. Una navegación de PRIMER NIVEL a un medio
(`Content-Type: video/*` o `audio/*`) se cancela y la URL se la lleva `mpv`, que ya viaja en las
cuatro imágenes de escritorio: esto no agrega ni una receta, es cablear lo que ya estaba.
MEDIO http://…/audio.wav
CANCELADA la navegación http://…/audio.wav
ABIERTO /usr/bin/mpv pid=266 http://…/audio.wav
mpv: AO: [null] 8000Hz mono 1ch u8 ← su propio log: DECODIFICÓ, no sólo arrancó
LA REGLA ES ESTRECHA A PROPÓSITO: sólo el primer nivel. Un `<video>` embebido es parte de la página y
sacarlo de ahí rompería el sitio que lo puso. Las reglas anchas en el camino de cada petición son las
que terminan rompiendo la web de alguien.
FAIL-OPEN, y es lo que prueba el control negativo: cancelar es SÍNCRONO y la respuesta del host llega
después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo con el
puerto vivo y, si el host contesta que no pudo, la URL VUELVE al navegador con una marca para no
entrar en bucle:
SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no
Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es justo el que se consigue si
uno confía en que salió bien.
⚠ EL BUG QUE ESTE GUARDIÁN DESTAPÓ VALE MÁS QUE LA FUNCIÓN, y estaba DESPUÉS del éxito aparente: la
primera corrida detectó el medio, canceló y recibió el pid… y después el puerto murió con «Native
application tried to send a message of 546281442 bytes». **Un hijo hereda los descriptores del padre,
y los del padre SON la tubería de native messaging**: `mpv` escribía su salida ahí y el navegador la
leía como un marco. Arreglado en tawasuyu (`2e99d216`, pineado acá) con tres redirecciones, cada una
contra un fallo distinto — `stdin` a null porque mpv LEE stdin y se comería los mensajes del
navegador; `stdout` al stderr del host y no a `/dev/null`, porque apagarlo arreglaría el bug y se
llevaría puesto el diagnóstico; `stderr` heredado por lo mismo.
Y dos cosas del ARNÉS, las dos ya vistas antes en esta misma sesión:
· la URL NO se pasa por la línea de comandos: una petición del arranque compite con la
inicialización de la extensión (la misma carrera del guardián de `sct`). El guardián navega a una
página que redirige a los 3 s, que además es lo que hace una persona: seguir un enlace;
· la evidencia de que el reproductor CORRIÓ se le pide a su propio `log-file`, porque su salida ya no
va al stdout del host y Gecko no vuelca el stderr del host al del navegador. Sin eso sólo se sabría
que hubo un `spawn`.
Del §6 quedan el foco (6.5), la IA local (6.7) —que además es la que traería el motor que le falta al
archivo del §6.3— y el torrent (6.9). Medido de paso: **el corpus no tiene ninguna receta de LLM ni de
embeddings**, así que 6.7 hoy no se puede pagar.
Medido sobre `atuq b3:a7080058` y `puriy-costura b3:060314e6`.
|
||
|
|
7f6459c132 |
atuq: el archivo personal — lo que leés queda congelado, y se encuentra
La unidad 8 del SDD 26 (§6.3), en su mitad pagable. Cada página que se lee se congela: el HTML **ya
ejecutado** —lo que la persona vio, no lo que mandó el servidor— va al CAS con su BLAKE3, y la url,
el título y el texto visible al índice. Después se busca, sin el navegador.
visita 1 (página A) ⇒ archivada, visitas=1, total=1
visita 2 (página A) ⇒ dedup, visitas=2, total=1 ← volver NO duplica
visita 3 (página B) ⇒ total=2
«masa» → 1 de 2 · «tobera» → 1 de 2 · «masa tobera» → 0 · «helicóptero» → 0
LA IDENTIDAD ES EL BLAKE3 DEL HTML, NO LA URL, y de ahí sale lo que hace que esto valga: la misma URL
con otro contenido ES otra página, así que el archivo tiene VERSIONES de lo que leíste. Un historial
de direcciones guarda punteros, y los punteros se pudren.
⚠ LA MITAD QUE NO SE PAGA HOY, dicha en el código, en el LEEME de allá y en el §6.3.bis: preguntarle
al historial en LENGUAJE NATURAL. Eso es el registro semántico y en la suite ya tiene forma —un motor
`rag-motor::RagMotor`, como `willay-rag`—, que necesita un daemon de embeddings y un backend LLM real
y devuelve `None` cuando no están. `archive.search` es el registro LITERAL: todas las palabras tienen
que aparecer. Enseñar lo uno diciendo que es lo otro sería el peor cambio posible.
LO QUE NO SE ARCHIVA ES LA PARTE QUE HAY QUE MIRAR: nada de una ventana privada, nada fuera del marco
principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto visible (el
HTML de un visor de PDF llenaría el archivo de cosas que no se encuentran). Y si no se puede saber si
la pestaña es privada, NO se archiva.
⚠ Y EL CONTROL NEGATIVO NO SÓLO COMPRUEBA QUE NO SE ARCHIVE: DICE QUIÉN LO IMPIDIÓ. La respuesta
medida no es la que uno supondría — hoy lo impide **el navegador**, porque las extensiones no corren
en ventanas privadas salvo que se las habilite, así que nuestro `if (incognito) return` no se ejecuta
nunca. No es código muerto: es lo que haría seguro habilitar `private_browsing` el día que haga
falta. Lo que sí habría sido un error es escribir «la extensión no archiva lo privado» sin saber cuál
de las dos cosas estaba pasando.
DOS TECHOS CON NOMBRE (en el host): 4 KiB de texto por página en el índice —es JSON para poder leerse
con `cat`, y uno sin techo deja de poder— y el corte por CARÁCTER y no por byte, porque `truncate`
sobre medio multibyte entra en pánico y en un archivo personal el texto con acentos es el caso normal.
Del lado de tawasuyu (`357791a8`, pineado acá): `archive.add`/`archive.search`, 6 tests nuevos (20 en
total) y el CAS renombrado de `descargas-cas` a `cas` — el mismo almacén guarda ahora lo bajado y lo
leído, y un nombre que describe la mitad de su contenido es el que se lee mal dentro de seis meses.
Medido sobre `atuq b3:5cfe3221` y `puriy-costura b3:c06f55aa`.
|
||
|
|
897fad178a |
atuq: una descarga deja de ser un archivo con nombre y pasa a ser un objeto con identidad
La unidad 7 del SDD 26 (§6.2). Cuando una descarga TERMINA, la extensión `descargas@atuq.tawasuyu` le pasa al host la ruta, el nombre y de dónde salió; el host la ingiere a un CAS BLAKE3 y contesta el hash, el tamaño y **cuántas veces se bajó ese mismo contenido**. El mismo contenido con otro nombre es UN objeto y dos nombres, y el navegador lo dice — que es exactamente lo que ningún navegador sabe hacer: para todos una descarga es un blob con nombre, y por eso la bajás dos veces y no te enterás. No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y tejido. El `<hex>` del CAS ES el `expected_hash` de un `.swm`. ⚠ TRES DECISIONES QUE SALIERON DE MEDIR, NO DE DISEÑAR: 1. **La raíz del CAS de descargas no es la del sistema.** `arje_cas::gc` borra todo blob que no esté en el set `reachable` de su llamador, y el único que existe (`arje-brain`, `GcCas`) lo arma con la cadena de audit y las raíces vivas del grafo. Una descarga del usuario no está en ninguno: el primer GC se la llevaría, en silencio. Van a `<estado>/descargas-cas` — mismo formato (mover un objeto al CAS del sistema es un `rename`), fuera del alcance del GC de otro. El precio queda escrito: tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo ahí. 2. **El fichero del usuario no se toca**: se copia y se deja donde estaba. El guardián lo comprueba byte a byte. Borrar o mover lo que alguien acaba de bajar no es decisión de un navegador. 3. **La extensión observa, no intercepta.** Se entera cuando la descarga terminó. Meterse en el medio obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual— es lo que se consigue no metiéndose. Y EL HALLAZGO QUE COSTÓ LA TARDE, que no es de esta unidad: **en `--headless` el navegador se cae con SIGSEGV en cuanto una descarga termina.** Se atribuyó con dos controles antes de tocar nada: --headless, con la extensión → baja el fichero, TERMINADA, Segmentation fault (139) --headless, SIN la extensión → baja el fichero, Segmentation fault (139) ← no es nuestro --headless, panel de descargas apagado → Segmentation fault (139) sway headless (compositor REAL) → baja, INGIERE al CAS, y no se cae ← no es del producto Sin el primer control esto se leía como «la extensión de descargas rompe el navegador» (perseguir un bug que no existe); sin el último, como «atuq no puede descargar» (reportar un bug de producto que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`, y por eso lo dice en su cabecera: un arnés distinto al de los demás guardianes, sin explicación, es una invitación a «simplificarlo» de vuelta al que se cae. Queda en el §6.10.bis del SDD, que es donde vive lo que atuq NO puede hacer. El guardián baja de verdad (`Content-Disposition: attachment`, no una llamada a la API) dos veces dentro de un mismo compositor, y trae control negativo: con OTRO contenido la segunda vez exige `dedup=false` y dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a una que funciona. Del lado de tawasuyu (`ffa939c6`, pineado acá): `cas.ingest`/`cas.list` en el host y `arje-cas::almacenar_fichero_en` — ingesta en streaming, porque `store` toma `&[u8]` y una ISO de 4 GiB serían 4 GiB de `Vec`. 5 tests nuevos en arje-cas y 5 en puriy-costura. Y EL BUG QUE EL GUARDIÁN DESTAPÓ, que vale más que la función: **hay un host por PUERTO, no uno por perfil.** Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en memoria del estado, y cada uno escribía el fichero ENTERO al guardar: el de descargas borraba el registro TOFU de `sct` al salir, y el de `sct` revertía el índice de descargas. Los dos contestaban bien — el daño estaba sólo en el disco. Se vio porque el guardián lee el índice EN DISCO en vez de creerle a la respuesta: decía `veces=2` y el fichero decía 1. Arreglado en tawasuyu (`55b918e8`, pineado acá): cada proceso escribe sólo la parte que tocó, y el test que lo fija se comprobó ROMPIENDO el arreglo a propósito — porque la primera versión de ese test abría los hosts en secuencia y pasaba con el bug puesto. |
||
|
|
02513b861f |
atuq: sct v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera,
medida de punta a punta con un servidor HTTP real y seis cargas de página:
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
carga 1-3 (mismo script) fase=learning eventos=0 insignia vacía
carga 4 (mismo script) fase=stable eventos=0 insignia vacía
carga 5 (mismo script, estable) fase=stable eventos=0 insignia vacía ← control
carga 6 (script CAMBIADO) fase=stable eventos=1 insignia "1"
EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B
La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, del otro lado
del cable — duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y el
primero es el que está certificado sin red.
CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas:
1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de
`XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko;
2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace
`command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state`
el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el
lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de
un usuario es layout del FHS, o sea asunto de la distro y no del crate;
3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja`
(se le preguntó al artefacto, no a la documentación de Mozilla);
4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una
PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN;
5. ⚠ **una carga de página puede producir dos peticiones del mismo documento, y una llega con
`tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
visitas. No es cosmético: inflar las visitas estabiliza el origen ANTES de conocer su código real,
y entonces alerta por churn legítimo — el falso positivo que la spec de puriy-sct pide evitar por
encima de todo. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error
seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga,
una visita», así que si vuelve, falla ruidoso.
DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable
del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no
bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje entre procesos en el camino
crítico de cada script de cada página no es «más seguro», es un navegador que nadie usa.
EL AVISO SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla,
y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única
parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final.
CONTROL NEGATIVO: `--negative-control` borra el manifiesto y exige que NO haya veredicto — o sea que
el veredicto de la corrida positiva viene del host y no de la extensión inventándolo.
Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada
despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se
cierra.
Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable
dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y
de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las
extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta.
`runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y
el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que
llega a las cuatro imágenes de escritorio.
⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya
decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos
cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni
con el de la v2, que engancha el script loader y ve los bytes reales.
Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre
el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y
la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después
la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre
un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la
del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta.
Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen
matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen
siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`.
|
||
|
|
97c35d67b3 |
atuq: dos cosas que el README daba por ciertas eran falsas, y las dos se midieron
El README de la receta afirmaba (a) que las extensiones las instala la POLÍTICA porque el sideloading desde `distribution/extensions/` «ya no funciona», y (b) que el chrome de verdad —split view— exige entrar a `omni.ja` y escribirlo nosotros. Las dos son falsas, y ninguna observación del artefacto las distinguía: hubo que romper un mecanismo por vez. scripts/test-atuq-instalacion.py — tres escenarios: as-is nada roto ⇒ las dos extensiones puestas no-policy `policies.json` sin ExtensionSettings ⇒ SIGUEN puestas policy-only los XPI fuera de la carpeta ⇒ NINGUNA, y la home vuelve a about:home ⇒ instala el ESCANEO de la carpeta; `install_url` con `file://` no instala nada. El §7.bis del SDD 26 tenía razón. El control es por construcción: la sonda vive en la carpeta y ninguna política la nombra, así que una corrida muda se declara ROTA en vez de leerse como «no instaló». Y la segunda señal que probé NO discrimina, queda dicho: el `location` de `extensions.json` sale `app-profile` también cuando instala la carpeta. scripts/test-atuq-chrome.py — el chrome se programa desde `atuq.cfg`, sin abrir el zip: observando `browser-delayed-startup-finished` se toca el `gBrowser` de cada ventana. Y la vista dividida ya la trae el motor (fx 154) PRENDIDA de fábrica, ejercitada de verdad —`addTabSplitView` ⇒ `activeSplitView` + 2 navegadores—, no «el fichero está». Dos pendientes que eran de upstream. Control negativo del propio motor: con las pestañas fijadas devuelve null (WRAPPER null, ACTIVA no). Corolario para lo que venga: cada función del chrome que NO entre a `omni.ja` es una que no pelea con el orden del `jarlog` del PGO — que es justo lo que tiene al jarlog aparcado. Queda escrito lo que NO está: ninguno de los `test-atuq-*` corre en el latido (sólo lo hace `vigia-sonames.py`), y meterlos cuesta ~3 min por ciclo más el rootfs hidratado en el hub. Medido sobre atuq b3:fab2fbfb → b3:8f6d09c2 (el README entra en el hash: documentar la medición re-hashea el artefacto medido, y los guardianes se niegan a medir uno que no sea el vigente). |
||
|
|
008dd3925e |
renombre: los instaladores pasan a takana-* — y el peligro no era el fichero, era el PROTOCOLO
ADR 0016 los listaba entre los CONGELADOS; el usuario pidió descongelarlos al abrir el SDD 28. La
enmienda queda escrita en el propio ADR, que si no la etiqueta deja de describir el hecho.
`hammer-install.sh` → `takana-install.sh`, `hammer-live-install.sh` → `takana-live-install.sh`,
`hammer-banner.txt` → `takana-banner.txt`, `BRIEFING-hammer.md` → `BRIEFING-takana.md`.
**Lo que hacía caro esto no es el nombre del fichero.** El instalador se inyecta en el ISO como
`/usr/bin/hammer-install` y su éxito se detecta con un `grep` de `HAMMER-INSTALL-OK` desde TRES
scripts de prueba. Renombrar un solo lado los deja casando NADA — sin fallar —, que es literalmente
el modo en que `atribuir-fallos.py` quedó mudo cuando el renombre movió el target de `tracing`.
Se renombraron las dos puntas en el mismo commit (`/usr/bin/takana-install`,
`TAKANA-INSTALL-OK/FAIL`, `TAKANA_INSTALL_*`, `work/takana-install.img`, `/run/takana-install`),
se comprobó por `grep` que no quede ningún token viejo fuera del ADR, y —lo que decide— se CORRIÓ
`install-tui-test.sh`: 4/4 casos verdes.
Las tres `TAKANA_INSTALL_*` caen al nombre viejo (`${TAKANA_INSTALL_X:-${HAMMER_INSTALL_X:-}}`):
el llamador puede ser un ISO anterior al renombre. Misma convención que `TAKANA_ROOT_PW` unas
líneas más arriba en ese mismo script.
NO se tocó `/usr/sbin/hammer-recover` ni su hook de arranque —renombrarlo rompe máquinas YA
INSTALADAS, no el repo—: sobrevive intacto dentro del script renombrado, verificado por conteo
antes y después (8 ocurrencias). Tampoco `hammerd`, `hammer-edit` (su `name` está en la ruta del
store), `/var/lib/hammer`, `HAMMER_LIVE` ni los siete literales de hash.
De paso: `scripts/.hammer-banner.txt.kate-swp` era un swap de editor commiteado por error. Fuera.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
807683dbc6 |
kde: XDG_MENU_PREFIX en los CINCO lanzadores — el menú de aplicaciones salía vacío
Arrancando la imagen KDE recién regenerada, el serial dijo:
"applications.menu" not found in QList("/etc/xdg/menus")
y el escritorio pintó perfecto igual: fondo, panel, reloj, bandeja. Ése es justo el modo de fallo
que este repo persigue — nada falla, simplemente el menú de aplicaciones no tiene qué mostrar.
⚠ Y EL DIAGNÓSTICO OBVIO ERA EL EQUIVOCADO. Lo primero que anoté fue «falta el fichero, hay que
empaquetarlo». **No falta**: `plasma-workspace` instala `/etc/xdg/menus/plasma-applications.menu`
—verificado en el store Y en el rootfs de la imagen, 9904 bytes—. Lo que faltaba es el PREFIJO:
Plasma construye el nombre como `${XDG_MENU_PREFIX}applications.menu`, y sin la variable busca
`applications.menu` a secas, que no existe con ese nombre en NINGUNA distro con Plasma. El error
nombra un fichero que nunca tuvo ese nombre. La etiqueta contra el hecho, otra vez.
El arreglo es una línea. Lo que no era una línea es DÓNDE: los cinco lanzadores de Plasma exportan
`XDG_DATA_DIRS` y ninguno exportaba éste —comprobado uno por uno—, así que arreglar sólo el de QEMU
habría dejado la misma trampa en metal, metal-sw, el anidado y el headless. Es el cable que este
repo ya vio caer cinco veces en `cosecha-cron.sh` y en la frontera del sembrador: arreglar el caso
y dejar la trampa. Van los cinco.
plasma-start-qemu.sh · plasma-start-metal.sh · plasma-start-metal-sw.sh export
nested-plasma.sh · run-plasma-headless.sh --setenv (arman con bwrap)
VERIFICADO, y digo exactamente qué: que el nombre que Plasma construirá con el prefijo EXISTE en la
ruta donde lo busca (`/etc/xdg/menus/plasma-applications.menu` está en el rootfs de la imagen). Lo
que NO está verificado es el menú renderizado — eso pide reconstruir la imagen y arrancarla otra
vez. El `plasma-start` horneado en el rootfs fundido ya se refrescó con el script corregido, así que
la imagen queda a un rebuild de distancia.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCt3ettR4Z7b6wPCBmbvEV
|