1577 lines
113 KiB
Markdown
1577 lines
113 KiB
Markdown
# SDD 26 — `atuq`: el envoltorio Gecko de la distro
|
||
|
||
Escrito 2026-09-05, a partir de la pregunta del usuario: *«ya habiendo compilado firefox y waterfox,
|
||
qué tan complicado vs provechoso suena crear nuestro propio envoltorio, en vez de zen?»*, y de su
|
||
aclaración de por qué la pregunta existe: **`puriy` quedó muy verde y es difícil sacarlo de ahí**.
|
||
|
||
`atuq` es *zorro* en quechua. El nombre estaba libre —verificado con `grep -ri atuq` sobre
|
||
`/mnt/vvv/takana` y `/mnt/vvv/tawasuyu`, cero coincidencias— y dice exactamente lo que la cosa es:
|
||
otro fox, con marca propia y sin usar la de Mozilla (ver el §branding de `recipes/firefox.toml`).
|
||
|
||
**Este documento no reemplaza a `puriy` ni lo cancela.** `puriy` sigue siendo el motor soberano; el
|
||
§9 explica por qué `atuq` es su andamio y no su desvío.
|
||
|
||
---
|
||
|
||
## 1. La corrección que ordena todo: no hay envoltorio fuera del chrome
|
||
|
||
La intuición natural —una carcasa Llimphi con nuestros widgets, y Gecko adentro pintando la página—
|
||
**no es posible**. Gecko no tiene API de embebido en escritorio desde que murió XULRunner; GeckoView
|
||
existe y es de Android. No hay forma soportada de meter el motor en una ventana de `mirada`.
|
||
|
||
⇒ un envoltorio de Gecko es, necesariamente, **chrome-level**: JS/CSS/XHTML *dentro* del propio
|
||
Firefox, más extensiones, más procesos externos que hablen con él.
|
||
|
||
Y eso es exactamente lo que Zen es. Zen no toca el motor: su diferencia entera —workspaces, split
|
||
view, glance, compact mode— vive en el chrome, más una herramienta (`surfer`) que reaplica sus
|
||
parches sobre el árbol de Firefox y recompila. **Su costo no es el motor: es la cinta de correr del
|
||
rebase cada cuatro semanas.** Copiar su método sería heredar su costo sin heredar su equipo.
|
||
|
||
## 2. La decisión de arquitectura: artefacto DERIVADO, no fork de fuente
|
||
|
||
Si `atuq` parchea el árbol, **cada iteración de UI cuesta un build de Gecko de cuatro horas**. Eso
|
||
mata el proyecto en la primera semana de diseño, porque el diseño de chrome es iterativo por
|
||
naturaleza.
|
||
|
||
Pero nosotros no distribuimos un binario: sellamos artefactos. Entonces `atuq` es una receta cuyo
|
||
`[deps]` incluye `firefox`, y cuyas fases copian el árbol instalado e inyectan encima:
|
||
|
||
| Qué se inyecta | Mecanismo de Firefox | Sin recompilar |
|
||
|---|---|---|
|
||
| Marca, iconos, nombre | `distribution/`, `application.ini` | ✅ |
|
||
| Prefs de fábrica | `defaults/pref/*.js` + autoconfig (`.cfg`) | ✅ |
|
||
| Política de empresa | `distribution/policies.json` | ✅ |
|
||
| Chrome propio (dientes, layout) | `userChrome.css` + re-empaque de `omni.ja` | ✅ |
|
||
| Extensiones nuestras | `distribution/extensions/` | ✅ |
|
||
|
||
**Ventajas que esto tiene sobre el método de Zen, y que son estructurales:**
|
||
|
||
1. **Iteración en segundos, no en horas.** El chrome se toca sin volver a compilar C++.
|
||
2. **No mueve el `ArtifactHash` de `firefox`.** El corpus no se invalida; `waterfox` y cualquier otro
|
||
fork siguen compartiendo el mismo artefacto base.
|
||
3. **Las CVE se heredan gratis.** Un fork de fuente te vuelve dueño del reloj de seguridad del
|
||
binario más atacado de la máquina — es lo que hundió a casi todos los forks de Firefox, y el
|
||
reproche histórico a Waterfox. Un derivado se reconstruye solo cuando sube `firefox`.
|
||
|
||
**Los dos gotchas reales del camino, que son trabajo pero chico:**
|
||
|
||
- **`omni.ja` es un zip ⇒ hay que re-empacarlo determinista** (mtimes fijos, orden estable) o el
|
||
artefacto deja de reproducir bit a bit. Es la misma disciplina que ya exige la imagen del lab.
|
||
- **El `jarlog` del PGO ordena el `omni.ja` para el arranque** (§3.bis). Re-empacarlo a lo bruto
|
||
tira esa optimización a la basura sin que nadie lo note. El re-empaque preserva el orden.
|
||
- **Hay que invalidar el startup cache** tras tocarlo (fichero `.purgecaches` junto al binario, o
|
||
bump del BuildID). Si no, los cambios no se ven y **parece que el overlay no agarró**: un falso
|
||
negativo de los caros, de la misma familia que el cache-hit que congela regresiones.
|
||
|
||
### 2.bis Cuándo SÍ hay que parchear el árbol
|
||
|
||
Cuando algo necesite tocar C++ del motor. Hoy conocemos un caso: `sct` en su forma fuerte (§6.1).
|
||
La regla es: **nada baja al árbol antes de que exista la toolchain del §3**, porque hasta entonces
|
||
cada intento cuesta cuatro horas. Y cuando baje, el vigía de parches (`scripts/vigia-parches.py`,
|
||
commit `aa200a4`) es lo que convierte esas cuatro horas en un minuto para saber si el parche agarra.
|
||
|
||
## 3. La puerta de las optimizaciones: no es un flag, es una toolchain
|
||
|
||
El usuario decidió habilitar las optimizaciones. Lo que hay que saber antes de intentarlo:
|
||
|
||
`recipes/firefox.toml` va con `compiler = "gcc"` por el **muro 3** (zig enlaza `libc++` estática para
|
||
musl y el `configure` de Mozilla exige encontrar un `NEEDED …libc++`; es negativa de upstream, no una
|
||
perilla). El problema es que **PGO, LTO y BOLT son cadena de clang en Gecko**:
|
||
|
||
- `--enable-lto=cross` quiere clang + lld. El LTO de GCC sobre Gecko no está soportado upstream.
|
||
- `--enable-profile-use` espera `-fprofile-instr-use` (clang), no `-fprofile-use` (gcc).
|
||
- BOLT necesita `llvm-bolt`.
|
||
|
||
Y **el corpus no tiene clang usable como compilador**. Verificado en las recetas, no supuesto:
|
||
|
||
- `recipes/clang18.toml` compila `ninja -C build libclang.so clang-resource-headers` y su fase
|
||
`install` copia **sólo** `build/lib/libclang.so*` y las cabeceras `clang-c`. Está ahí porque
|
||
`bindgen` carga `libclang.so` en runtime. **No hay driver `clang`, ni `lld`, ni `libc++`.**
|
||
- `llvm18` se selló con `-DLLVM_ENABLE_PROJECTS=""` — LLVM a secas.
|
||
|
||
⇒ la primera versión de este documento concluyó que hacía falta **una receta `llvm-toolchain`
|
||
(clang+lld+libc++ desde fuente)**. **Era caro de más, y se corrigió el mismo día mirando el lab en
|
||
vez de suponerlo:**
|
||
|
||
- `.dev-fs/alpine` **ya trae clang22 + llvm22 22.1.8** — la misma major que usa el APKBUILD de
|
||
Alpine para este mismo Firefox (`_llvmver=22`).
|
||
- **`Compiler::Clang` ya existe en takana**, cableado de punta a punta (`parse_compiler` lo acepta;
|
||
`takana-build` pone `CC=clang`, `CXX=clang++`, `AR=llvm-ar`). Ninguna receta lo usaba.
|
||
- Faltaba **sólo `ld.lld`**: `apk add lld` ⇒ dos paquetes, cero upgrades.
|
||
|
||
Y los tres muros caen sin perder lo que gcc daba: el sondeo de linker se satisface con
|
||
`--enable-linker=lld`, el `ar` lo pone takana solo, y el `NEEDED` de la stdlib de C++ existe porque
|
||
clang++ de Alpine usa la `libstdc++` **compartida** — la prueba no es teórica: Alpine construye este
|
||
Firefox con clang22 y **sin** `libcxx` en sus makedepends.
|
||
|
||
**La huella del lab no se movió, y se midió antes de tocar nada.** `lld` no casa ningún prefijo de
|
||
`TOOLCHAIN_PREFIXES` (`takana-core/src/lab.rs`), así que los 43 paquetes que entran en `hash_inputs`
|
||
salieron idénticos ⇒ **los 837 artefactos sellados quedan intactos**. Eso abarata el cambio hoy y a
|
||
la vez deja un agujero escrito: la versión de `lld` no es parte de la identidad del artefacto, y sólo
|
||
expone a las recetas `compiler="clang"` — hoy, una. Cerrarlo cuesta re-hashear el corpus entero.
|
||
|
||
**Hecho el 2026-09-05** (commit `cb3ecd5`): `firefox` va con `compiler = "clang"`,
|
||
`--enable-linker=lld`, `--enable-lto=cross`, `--enable-packed-relative-relocs` y
|
||
`--with-unsigned-addon-scopes=app,system`. Falta construirlo.
|
||
|
||
**Riesgos de PGO, escritos antes de empezar:**
|
||
|
||
- El PGO de Mozilla **corre el navegador** para juntar el perfil (`profileserver.py`, headless +
|
||
marionette). Dentro de un sandbox hermético, sin red y sin X11, eso hay que hacerlo andar.
|
||
Headless no necesita display, pero sí necesita que el árbol de perfil se genere adentro.
|
||
- **El perfil resultante es un input del artefacto.** O entra en `hash_inputs`, o `atuq` deja de ser
|
||
determinista y no lo vamos a notar: sería otra forma del lab que no está en `hash_inputs`.
|
||
- Ganancia esperable, para calibrar expectativas: PGO+LTO en Gecko son del orden de 10-25% en carga
|
||
de página y JS. **Hoy estamos ATRÁS de Zen en este eje, no adelante** — Zen hereda la
|
||
configuración de Mozilla tal cual. Esto no es una ventaja nuestra: es una deuda que se salda.
|
||
|
||
### 3.bis El diff contra Alpine — y no es sólo PGO
|
||
|
||
Medido el 2026-09-05 trayendo el `APKBUILD` y el `mozconfig` de `community/firefox` de aports y el
|
||
`PKGBUILD` de Arch, y comparándolos línea a línea con el mozconfig de `recipes/firefox.toml`.
|
||
**Alpine es nuestro propio upstream** —de ahí salen los once parches de musl— así que la comparación
|
||
no es contra una distro lejana: es contra la que ya construye este mismo código.
|
||
|
||
| | `recipes/firefox.toml` | Alpine `community/firefox` |
|
||
|---|---|---|
|
||
| LTO | **no** | `--enable-lto=cross` |
|
||
| PGO | **no** | `--enable-profile-use=cross` + `merged.profdata` + `jarlog` |
|
||
| Linker | GNU ld (via gcc) | `--enable-linker=lld` (lld 22) |
|
||
| RELR | **no** | `--enable-packed-relative-relocs` |
|
||
| Sandbox RLBox | **APAGADO** (`--without-wasm-sandboxed-libraries`) | `--with-wasi-sysroot` |
|
||
| Extensiones sin firmar | **no declarado** | `--with-unsigned-addon-scopes=app,system` |
|
||
| Allocator | `--disable-jemalloc` | `--disable-jemalloc` — **igual, no es desviación nuestra** |
|
||
| Librerías | todo bundleado | `--with-system-{icu,nss,nspr,av1,vpx,webp,png,jpeg,zlib,ffi,libevent,pixman,pipewire}` |
|
||
|
||
Arch hace lo mismo en el eje de velocidad («Do 3-tier PGO», `--enable-lto=cross`,
|
||
`--enable-profile-use=cross`). **Ni Alpine ni Arch usan BOLT** (cero menciones en los dos ficheros)
|
||
⇒ BOLT no es la desventaja, es el extra. Lo que nos separa del resto del mundo es **PGO + LTO**, del
|
||
orden de 10-20% en carga de página y JS.
|
||
|
||
**Tres lecturas que hay que sacar de esa tabla:**
|
||
|
||
1. **RLBox apagado es una desventaja de SEGURIDAD, no de velocidad, y probablemente pesa más que el
|
||
PGO.** Es la jaula wasm alrededor de los parsers de fuentes y medios —graphite, ogg, expat,
|
||
woff2—, o sea exactamente el código que come entrada no confiable. Encenderlo pide `wasi-sdk` +
|
||
`wasi-compiler-rt` en el corpus: receta propia, no un flag.
|
||
2. **`--with-unsigned-addon-scopes=app,system` nos falta y `atuq` lo NECESITA** para shipear sus
|
||
propias extensiones desde `distribution/extensions/` (§6.1, §7). Es un flag de `configure` ⇒
|
||
**una capacidad de `atuq` que no se resuelve en el overlay: exige tocar la base.**
|
||
3. **Las librerías del sistema son diferencia a propósito, no deuda.** Alpine usa las suyas; nuestro
|
||
corpus *es* la fuente y bundlear es lo que mantiene la clausura cerrada.
|
||
|
||
**Corolario que ordena el plan: todo esto entra en UN SOLO rebuild de `firefox`.** Cada rebuild son
|
||
cuatro horas y re-sella la cola Gecko entera (waterfox incluido), así que LTO, PGO, RELR, lld, RLBox
|
||
y el scope de addons se juntan en una sola pasada, no en seis.
|
||
|
||
### 3.ter Los dos muros del PGO que las distros no tienen
|
||
|
||
**El perfil se junta corriendo el navegador, y las dos distros lo hacen bajo `xvfb-run`** (Alpine:
|
||
`xvfb-run -a -s "-screen 0 1920x1080x24" ./mach python build/pgo/profileserver.py`; Arch, idéntico).
|
||
**Nosotros no tenemos X11 en el corpus** —es la decisión Wayland-only de toda la distro— así que ese
|
||
camino no existe acá. La salida takana-nativa es correrlo bajo un **sway headless**
|
||
(`WLR_BACKENDS=headless`), que ya tenemos del frente wlr/sway y arranca en segundos.
|
||
|
||
**✅ EL MURO DEL DISPLAY ESTÁ DESPEJADO (2026-09-06).** No es una previsión: se corrió. `sway`
|
||
headless con `WLR_BACKENDS=headless` y `WLR_RENDERER=pixman` arranca en **gioser**, que es un LXC
|
||
**sin `/dev/dri`** —o sea sin GPU ninguna, todo software— y dibuja píxeles de verdad:
|
||
`docs/evidencia/sway-headless-gioser-sin-gpu-2026-09-06.png` (sway 1.10 / wlroots 0.18.2 /
|
||
foot 1.27.0, los tres binarios nuestros, salida `HEADLESS-1` a 1280x720). El ciclo entero es de
|
||
~2 min con `scripts/wlr/sway-headless.sh`.
|
||
|
||
Dos cosas que hubo que resolver y conviene tener escritas antes de repetirlo:
|
||
· **Hidratar bajo el montaje del store.** `hydrate-profile.py` ENLAZA, y un hardlink no cruza
|
||
montajes: con `--into work/...` sale `EXDEV` porque el store vive en otro disco. Con
|
||
`--into store/.rootfs/sway` son 193/193 nodos, 41.677 ficheros y **4,9 G que no ocupan** (son
|
||
hardlinks al store).
|
||
· **Inyectar el loader de musl.** `sway` salió DINÁMICO y pide `/lib/ld-musl-x86_64.so.1`, que el
|
||
cierre no trae; el error es `exec: /usr/bin/sway: not found`, que se lee como «falta el binario»
|
||
y lo que falta es su intérprete. Se resuelve con
|
||
`--ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1` (en musl el `libc.so` ES el
|
||
loader). Es la misma familia que el resto de la noche: el mensaje nombra lo que buscó, no lo que
|
||
falta.
|
||
|
||
**Y el perfil no es determinista.** Los contadores dependen del timing de la corrida, así que dos
|
||
generaciones del profdata no dan los mismos bytes — a las distros no les importa porque no persiguen
|
||
bit-repro; a nosotros nos rompe el invariante. La salida: **generar el perfil UNA vez y sellarlo como
|
||
artefacto propio, consumido por hash** desde `firefox`. El build optimizado vuelve a ser determinista
|
||
aunque su insumo no lo sea, y el perfil se regenera a propósito, no por accidente.
|
||
|
||
### 3.quinquies El PGO, y los tres muros que el plan no tenía escritos (2026-09-07)
|
||
|
||
El §3.ter daba dos muros: el display (X11 que no tenemos) y el no-determinismo del perfil. Los dos
|
||
eran ciertos. Aparecieron **tres más**, y ninguno se ve sin construir:
|
||
|
||
1. **`libclang_rt.profile.a` no existía.** El lab trae clang 22.1.8 pero **ninguna runtime de
|
||
compiler-rt** — su `lib/` del resource dir ni existe, exactamente como ya documentaba
|
||
`wasi-compiler-rt` para el caso wasm. El build instrumentado murió en el minuto 38:56 con
|
||
`ld.lld: cannot open …/libclang_rt.profile.a`. De ahí sale `recipes/compiler-rt-profile.toml`.
|
||
|
||
2. **Los sandboxes de Firefox matan la corrida de perfilado.** Con ellos puestos los procesos hijo
|
||
mueren con **signal 11**, el que renderiza no llega a nacer, el servidor local registra **0 GET**
|
||
y el único `.profraw` que queda es el del padre arrancando — un perfil de nada, con todo en
|
||
verde. Es lo mismo que hace el `profileserver.py` de Mozilla. Sólo aplica a la corrida de
|
||
entrenamiento: el firefox que se distribuye lleva sus sandboxes intactos.
|
||
|
||
3. **El worker no puede bajar del mirror de fuentes.** Tiene `scripts/fuentes/mirror-env.sh` desde
|
||
el 2026-09-01 pero no la clave del Storage Box, así que una receta pineada al mirror —y el perfil
|
||
es la primera— no se construye allá. Se sembró el artefacto sellado, que es content-addressed:
|
||
consigue lo mismo sin mover una credencial de máquina.
|
||
|
||
**Cómo se verificó que el PGO llegó, y por qué NO como RLBox.** Con RLBox el artefacto delata la
|
||
jaula (símbolos `w2c_*`, cero → 634). Acá el discriminante análogo serían las secciones
|
||
`.text.hot`/`.text.unlikely` que clang emite al particionar por temperatura — y **no sirve**: con
|
||
lld y ThinLTO el enlazador las fusiona, así que dan cero en el binario con PGO y sin él. La
|
||
evidencia que sí vale está un nivel por debajo del `configure`: **189 invocaciones del compilador
|
||
llevan `-fprofile-use`**, el configure encontró `llvm-profdata`, y no hay ni un aviso de perfil que
|
||
no cuadre. `libxul.so` pasa de 226.078.752 a 227.802.816 bytes, consistente con inlining de caminos
|
||
calientes. Es evidencia de build, no de artefacto, y conviene decirlo así.
|
||
|
||
⚠⚠ **LEER PRIMERO: LOS PORCENTAJES DE LAS TRES TABLAS QUE SIGUEN ESTÁN DILUIDOS Y NO SON LO
|
||
QUE PARECEN.** Miden el ciclo completo del proceso, del que **~16 s son arranque**. La corrección,
|
||
con el control que faltaba, está más abajo en «EL CONTROL QUE FALTABA». Se dejan tal cual se
|
||
publicaron, sin retocar, porque el error de método es el que enseña.
|
||
|
||
**LA GANANCIA, MEDIDA (2026-09-07).** Mismo rootfs, mismo script, corridas INTERCALADAS A/B/A/B
|
||
para que la deriva de carga afecte a las dos, mediana de 7, calentamiento descartado. Lo único que
|
||
cambia entre variantes es un `--ro-bind` de `/usr/lib/firefox`.
|
||
|
||
| página | sin PGO | con PGO | |
|
||
|---|---|---|---|
|
||
| DOM+layout+strings, **fuera** del corpus de entrenamiento | 23.540 ms | 21.327 ms | **−9,4 %** |
|
||
| SunSpider `3d-raytrace`, **dentro** del corpus | 16.034 ms | 15.878 ms | −1,0 % |
|
||
|
||
Los rangos no se solapan en ninguna de las dos (23.185–23.654 vs 21.030–21.650), así que las dos
|
||
diferencias son reales y no ruido.
|
||
|
||
⚠ **Y el resultado sale al revés de lo esperable, que es lo interesante.** La intuición dice que
|
||
medir sobre el conjunto de ENTRENAMIENTO infla la ganancia; acá el corpus da el número MÁS BAJO. La
|
||
razón es que `3d-raytrace` es aritmética pura en un bucle caliente, y ese bucle **no lo ejecuta el
|
||
C++ de SpiderMonkey: lo ejecuta código máquina que el JIT genera en tiempo de ejecución**. El PGO
|
||
optimiza el intérprete, el GC y el propio compilador JIT — no el código que el JIT emite. Un
|
||
benchmark JIT-bound es casi ciego al PGO por construcción.
|
||
|
||
Donde sí se ve es en el camino DOM/layout/arranque, que es C++ de principio a fin. Y eso es también
|
||
lo que un usuario percibe: la medición cubre el ciclo COMPLETO del proceso (arrancar → renderizar →
|
||
capturar → salir), no el rendimiento en régimen de una página ya cargada. De ahí que hasta la página
|
||
de SunSpider tarde 16 s: casi todo es arranque.
|
||
|
||
**Corolario para el corpus de entrenamiento — APLICADO Y MEDIDO (2026-09-08).** El corpus estaba
|
||
sesgado hacia JS justo donde el PGO menos rinde, así que se le sumaron 10 páginas de maquetación
|
||
propias (`scripts/pgo-corpus/`): flexbox, grid, tablas con los dos algoritmos de layout, texto en
|
||
columnas, selectores contra 600 reglas, pintado, transforms, SVG, scroll pegajoso y layout
|
||
thrashing. El perfil pasó de 5.885.254.799 ejecuciones registradas a **38.498.366.335 (6,5×)**.
|
||
|
||
| | sin PGO | PGO v1 (36 pág.) | PGO v2 (46 pág.) |
|
||
|---|---|---|---|
|
||
| DOM/maquetación, fuera del corpus | 23.285 ms | 21.050 ms (−9,6 %) | **20.640 ms (−11,4 %)** |
|
||
| SunSpider `3d-raytrace` | 16.003 ms | 15.846 ms (−1,0 %) | 15.857 ms (−0,9 %) |
|
||
|
||
**La mejora en maquetación es real y no de medianas:** SEIS de las siete muestras de v1 son más
|
||
lentas que TODAS las de v2; sólo una cae dentro del rango de v2. En SunSpider, en cambio, v1 y v2
|
||
son **indistinguibles** —los valores se entrelazan por completo— que es justo lo esperable: ese
|
||
camino lo ejecuta el JIT y el PGO no lo toca. Un corpus mejor no puede mejorar lo que el PGO no
|
||
alcanza.
|
||
|
||
⚠ Una de las siete corridas de v2 dio 120.029 ms contra ~20.600 de las otras seis: un atípico
|
||
transitorio. **La mediana lo ignora por diseño**; una media lo habría convertido en «v2 es
|
||
catastróficamente peor». Fue la razón de elegir mediana antes de ver un solo número.
|
||
|
||
**EL JARLOG: PROBADO, MEDIDO, Y APARCADO PORQUE ROMPE `atuq` (2026-09-08).** Desde
|
||
`d9833a58` el perfil trae también el `jarlog` y `firefox` lo consume con `--with-pgo-jarlog`. No
|
||
hizo falta la extensión `Quitter` de Mozilla, que este documento daba por bloqueante: su
|
||
`profileserver.py` sólo traduce `JARLOG_FILE` a `MOZ_JAR_LOG_FILE` en el entorno del navegador.
|
||
|
||
| | sin PGO | v1 (36 pág.) | v2 (46 pág.) | v3 (= v2 + jarlog) |
|
||
|---|---|---|---|---|
|
||
| DOM/maquetación | 23.008 ms | 20.851 (−9,4 %) | 20.527 (−10,8 %) | 20.502 (−10,9 %) |
|
||
| SunSpider | 16.006 ms | 15.859 (−0,9 %) | 15.869 (−0,9 %) | 15.838 (−1,0 %) |
|
||
|
||
**El jarlog solo aporta −0,1 % y −0,2 %: indistinguible de cero.** Y el argumento no es que los
|
||
rangos se solapen —que se solapan— sino que **la misma variante varía ~1 % entre corridas de días
|
||
distintos** (v1 dio 20.851 hoy y 21.050 ayer). El suelo de ruido del banco es **diez veces** el
|
||
efecto buscado.
|
||
|
||
Eso NO es un fallo del jarlog: es que el banco corre con la **caché de página caliente**, y ahí
|
||
reordenar `omni.ja` no ahorra ninguna lectura. Su terreno es el arranque EN FRÍO, que este
|
||
instrumento no reproduce; medirlo pediría vaciar la caché entre corridas, que es una acción de
|
||
sistema. **Lo que sí está probado es que la función existe**, y del lado del artefacto: `libxul` es
|
||
IDÉNTICO byte a byte entre v2 y v3 y el `omni.ja` difiere en 26 bytes — el jarlog tocó exactamente
|
||
lo que debía tocar y nada más. **Y sí costaba.** Al reconstruir `atuq` sobre ese firefox, murió:
|
||
|
||
zipfile.BadZipFile: Bad magic number for central directory (rebrand.py)
|
||
|
||
El jarlog convierte el `omni.ja` al **formato «jar optimizado» de Mozilla**, que mueve el directorio
|
||
central al principio:
|
||
|
||
sin jarlog: PK\003\004 ZIP estándar — `zipfile` lo abre, 5306 entradas
|
||
con jarlog: \376\204#\0 `zipfile` lo rechaza
|
||
|
||
Y `atuq` reempaqueta el `omni.ja` con `zipfile` para su branding. O sea que el jarlog **rompe el
|
||
navegador propio de la distro**.
|
||
|
||
**Aparcado, y la cuenta es asimétrica:** el coste está MEDIDO y el beneficio NO. Cambiar algo que
|
||
funciona por una ganancia que no se pudo medir, rompiendo algo que sí funcionaba, es mal negocio.
|
||
Se retoma cuando (1) se mida el arranque EN FRÍO, que es su terreno, y (2) `rebrand.py` sepa leer el
|
||
jar optimizado o des-optimizarlo antes. El blob `92497cdd…` del mirror ya trae el jarlog, así que
|
||
retomarlo es cambiar una línea y re-pinear el sha256 — no volver a perfilar.
|
||
|
||
⚠ **Y la lección de método, que es la que más vale:** el jarlog parecía GRATIS. «Es lo que hace
|
||
upstream y no cuesta nada» — lo escribí yo, en este documento, hace unas horas. El coste no apareció
|
||
midiendo el jarlog sino **construyendo lo que dependía de él**. Una función que se declara gratuita
|
||
sin haber reconstruido a sus consumidores no es gratuita: es no medida.
|
||
|
||
⚠ **Dato que los datos delatan:** los dos valores atípicos del banco son 120.034 y 120.030 ms, o sea
|
||
exactamente el `timeout 120` del arnés. No son ruido de carga: son corridas COLGADAS al arrancar en
|
||
headless, 2 de 56 (~3,5 %). Sin perseguir, pero anotado.
|
||
|
||
⚠ Y lo que este banco NO puede afirmar: las 10 páginas nuevas ejercitan los mismos subsistemas que
|
||
la página de medición, así que el parentesco con lo que ahora se entrena es mayor que antes. El
|
||
−11,4 % es real para ESTA carga; generalizarlo a «cualquier página» sería justo el error que el
|
||
propio corpus de Mozilla cometía al revés.
|
||
|
||
## EL CONTROL QUE FALTABA: el arranque eran 16 s, y sin restarlo ningún porcentaje significa nada (2026-09-08)
|
||
|
||
Todo lo de arriba mide **el ciclo completo del proceso**: arrancar firefox, renderizar, capturar el
|
||
PNG y salir. Nunca se midió cuánto de eso era la página. Faltaba el control más barato posible —una
|
||
página VACÍA— y con él las cifras publicadas se leen distinto:
|
||
|
||
| carga | total sin PGO | total con PGO | **trabajo** sin → con | mejora del TRABAJO |
|
||
|---|---|---|---|---|
|
||
| `vacia.html` (arranque puro) | 16.026 ms | 15.883 ms | — (ES la línea base) | **−0,9 %** |
|
||
| DOM/maquetación | 23.307 ms | 20.627 ms | 7.281 → 4.744 ms | **−34,8 %** |
|
||
| carga ajena (canvas/JSON/img) | 20.576 ms | 20.517 ms | 4.550 → 4.634 ms | **+1,8 % (nada)** |
|
||
| SunSpider `3d-raytrace` | 15.992 ms | 15.877 ms | **−34 → −6 ms** | bajo el ruido |
|
||
|
||
Las cuatro cargas y las dos variantes, **en una sola sesión intercalada**: restar el arranque de una
|
||
corrida al total de otra no vale, porque la misma variante deriva ~1 % entre días.
|
||
|
||
**Qué cambia, en concreto:**
|
||
|
||
1. **El «−10,9 %» que publiqué es correcto como número y engañoso como afirmación.** Es real para el
|
||
ciclo completo, que es lo que un usuario espera al abrir el navegador. Pero se leía como
|
||
«firefox es un 11 % más rápido», y sobre el trabajo de la página la mejora es **−34,8 %**: el
|
||
PGO rinde TRES VECES más de lo que dije. El error subestimaba mi propio resultado.
|
||
|
||
2. **La explicación que di del SunSpider era aire.** Escribí que el −1,0 % se explicaba porque un
|
||
benchmark JIT-bound es ciego al PGO. Mirá la fila: su trabajo mide **−34 ms**, negativo — la
|
||
página con el benchmark tardó MENOS que la vacía. `3d-raytrace` corre en decenas de ms y su
|
||
trabajo es más chico que la variación entre corridas. **Ahí no había nada que medir.** El −1,0 %
|
||
era el −0,9 % del arranque, y yo le colgué encima una teoría sobre el JIT.
|
||
|
||
La teoría puede ser cierta —el mecanismo es real y está bien descrito— pero **esta medición nunca
|
||
la probó**, y la presenté como si sí. Un número real más un mecanismo real no hacen una
|
||
explicación: hace falta que el número mida el mecanismo.
|
||
|
||
3. **La carga ajena confirma que el PGO no es magia.** +1,8 % sobre su trabajo, o sea cero: mejora
|
||
lo que se le entrenó (maquetación, −34,8 %) y no lo que no (canvas/JSON, 0 %). Eso es exactamente
|
||
lo que un PGO honesto debe hacer, y es mejor evidencia de que funciona que el número grande.
|
||
|
||
4. **El jarlog no se rehabilita.** Su −0,1 % diluido pasa a ~−0,5 % sin diluir; sigue muy por debajo
|
||
del ~1 % que la misma variante deriva entre días. La conclusión de aparcarlo no se mueve.
|
||
|
||
⚠ **La lección, que es la misma de toda esta jornada con otro disfraz.** El instrumento funcionaba,
|
||
las medianas eran correctas, las corridas estaban intercaladas, los rangos no se solapaban — todo el
|
||
rigor estadístico estaba puesto **sobre una cantidad que no era la que yo creía estar midiendo**.
|
||
Ninguna cantidad de repeticiones lo habría revelado: sólo lo revela un control, y el control costó
|
||
siete corridas de una página vacía. **Antes de refinar la precisión de un número, hay que gastar una
|
||
medición en averiguar qué mide.**
|
||
|
||
Y el detalle que lo vuelve barato de recordar: el control salió NEGATIVO en una fila. Un valor
|
||
imposible es la forma más limpia que tiene una medición de avisar que el modelo está mal, y por eso
|
||
esa fila se deja publicada con su signo menos en lugar de redondearla a cero.
|
||
|
||
**Y la pregunta que un insumo no determinista obliga a hacer, respondida: `firefox` SIGUE
|
||
REPRODUCIENDO bit a bit** (`verificar-repro.sh`: REPRODUCEN 1, DERIVA 0, NO-DETERMINISMO 0). Era el
|
||
riesgo real de esta unidad, no el rendimiento: si el `-fprofile-use` hubiera metido cualquier
|
||
decisión dependiente del orden o del timing, habríamos cambiado velocidad por el invariante del
|
||
proyecto. Congelar el perfil como fuente pineada era el argumento; esto es la medición.
|
||
|
||
### 3.quater La cadena wasm, y las tres cosas que costó (2026-09-06)
|
||
|
||
`--without-wasm-sandboxed-libraries` se cambió por `--with-wasi-sysroot=/usr/share/wasi-sysroot`,
|
||
que es literalmente el flag de Alpine. Detrás hay cinco recetas nuestras, en este orden obligatorio:
|
||
|
||
```
|
||
wasi-libc-headers → wasi-compiler-rt → wasi-libc → wasi-libcxx → wasi-sdk → firefox
|
||
```
|
||
|
||
`wasi-libc-headers` existe **sólo** para romper el ciclo del bootstrap y pinea un commit VIEJO a
|
||
propósito (triple `wasm32-wasi`), mientras `wasi-libc` pinea el nuevo (`wasm32-wasip1`, el que
|
||
clang 22.1 usa de verdad). Es lo mismo que hace Alpine y no es un descuido.
|
||
|
||
**Lo que costó, que es la parte reusable:**
|
||
|
||
1. **`check-symbols` falla por sesgo de clang, no por un artefacto malo.** El lab trae clang 22.1.8
|
||
y el snapshot del árbol viene de uno anterior: sobra `#define __wasip1__ 1` y nada más. Se
|
||
reconcilia el snapshot; **no** se salta con `make no-check-symbols`, que se lleva por delante la
|
||
comparación de símbolos —la mitad que sí vale y que pasaba byte a byte.
|
||
|
||
2. **Faltaba `wasi-libcxx`, y el hueco era visible en el grafo sin construir nada.** El configure
|
||
murió a los 6 s con `'cstring' file not found`, culpando a los headers de WASI, que estaban
|
||
perfectos: `<cstring>` es C++ y las librerías enjauladas no son todas C. El `wasi-sdk` de Alpine
|
||
declara `wasi-libc wasi-libcxx wasi-compiler-rt` —TRES— y la cadena tenía dos. **El `depends`
|
||
del paquete ajeno equivalente es una comprobación de completitud gratis**, antes de gastar CPU.
|
||
|
||
3. **Un directorio VACÍO que sí importa.** Con `wasi-libcxx` sellado el configure volvió a morir
|
||
igual, y `cstring` estaba en el artefacto: clang no lo buscaba. Falta el `mkdir -p
|
||
include/c++/v1` que Alpine tiene en su `package()` y que parece ruido de empaquetado — es la
|
||
sonda por la que el driver decide que el sysroot tiene layout de libc++. Medido dentro del lab:
|
||
sin ese directorio, `'cstring' file not found`; con él, exit 0. Es el **reverso exacto de la
|
||
regla 3 del `CLAUDE.md`**: allá un directorio vacío es un artefacto mentiroso, acá es una
|
||
declaración dirigida al compilador.
|
||
|
||
De ahí sale el criterio de guardián que ahora usan estas recetas: **«existe» no es «se encuentra»**.
|
||
`wasi-libcxx` corre hoy la misma prueba que hace el configure de firefox, contra un sysroot
|
||
fusionado con el de su dep, así que un fallo aparece en el segundo 20 de una receta chica —con la
|
||
lista de búsqueda de clang impresa— y no en el segundo 6 de un build de cuatro horas que además
|
||
culpa a otro. Y `firefox` exige RLBox **en el binario**: sobre `b3:352d7880` (el último con RLBox
|
||
apagado) los patrones `w2c_`, `rlbox` y `wasm2c` dan cero los tres en `libxul.so`, así que
|
||
cualquiera apareciendo prueba que la jaula entró. Es la tercera repetición de la lección de
|
||
`MOZ_REQUIRE_SIGNING` y `MOZ_BUILD_DATE`: una bandera que el configure acepta y el artefacto ignora.
|
||
|
||
**Evidencia de reproducibilidad cruzada (2026-09-06).** Las cinco recetas se construyeron por
|
||
separado en las DOS máquinas —el hub (gioser, 4c) y el worker (`dev.gioser.net`, LXC 6c)— y los
|
||
cinco artefactos salen **byte a byte idénticos**, comparando el árbol completo de cada uno:
|
||
|
||
| receta | sha256 del árbol | |
|
||
|---|---|---|
|
||
| `wasi-libc-headers` | `0d34f9cb27530606…` | ✓ |
|
||
| `wasi-compiler-rt` | `0c4fc6fdf08ef908…` | ✓ |
|
||
| `wasi-libc` | `c13acdf896f2b78c…` | ✓ |
|
||
| `wasi-libcxx` | `b1d9aeec08499063…` | ✓ |
|
||
| `wasi-sdk` | `b49f7a74a5caed3f…` | ✓ |
|
||
|
||
No es un trámite: **el lab NO entra en `hash_inputs`**, así que dos labs distintos pueden sellar
|
||
bytes distintos en la misma dirección del store y nada lo detecta. Esto dice que para esta cadena
|
||
los dos labs coinciden de verdad, y no sólo que coinciden los `ArtifactHash`.
|
||
|
||
## 4. Lo que `atuq` NO promete
|
||
|
||
Esta sección existe antes que la lista de features a propósito, con el mismo criterio que el modelo
|
||
de adversario de `qullqa`: **lo que no se promete se escribe primero, porque una promesa falsa de
|
||
privacidad es peor que no ofrecer nada.**
|
||
|
||
**`atuq` no es Tor Browser y no va a tener «modo Tor».** Tor Browser no es «Firefox + SOCKS»: su
|
||
valor son las contramedidas de huella (RFP, letterboxing, normalización de fuentes/canvas/timers) y
|
||
sobre todo **un conjunto de anonimato donde todos se ven idénticos**. `atuq` es, por construcción, un
|
||
binario único en el mundo: musl, Wayland-only, branding propio, extensiones propias. Alguien saliendo
|
||
por Tor desde `atuq` es **más** identificable, no menos, y su conjunto de anonimato son diez
|
||
personas. Para anonimato: Tor Browser, y lo decimos nosotros primero.
|
||
|
||
Lo que sí se ofrece, y es honesto porque es otra cosa: **proxy por contenedor** (§6.8) — separación
|
||
de tráfico, no anonimato.
|
||
|
||
**`atuq` tampoco promete** anti-fingerprinting propio (es un programa de investigación con equipo
|
||
full-time) ni bloqueo de anuncios propio (se shipea uBO y listo).
|
||
|
||
## 5. Contexto verificado al escribir (2026-09-05)
|
||
|
||
De `docs/state/build-state.json`, no de memoria:
|
||
|
||
| Dato | Valor |
|
||
|---|---|
|
||
| Recetas / nodos | 838 / 840 |
|
||
| Selladas | 837 |
|
||
| `firefox` 154.0 | **sealed**, cola `corpus` |
|
||
| `waterfox` 6.7.1.1 | **never** — primer build en curso |
|
||
| Plataforma compartida | `gtk3`, `atk`, `nodejs`, `clang18`, `cbindgen`: todas sealed |
|
||
|
||
**`atuq` no arranca hasta que `waterfox` selle.** Waterfox es la prueba de la tesis del reúso —«las
|
||
diferencias reales con `firefox.toml` son TRES: la fuente, el branding y la versión»—. Si esa tesis
|
||
falla, el precio de todo este documento cambia y hay que releerlo.
|
||
|
||
## 6. Los diferenciadores, priorizados
|
||
|
||
La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
|
||
|
||
| # | Qué | Quién más lo tiene | Pieza que ya existe | Costo |
|
||
|---|---|---|---|---|
|
||
| 6.1 | **`sct` — transparencia de scripts** | **nadie** | `puriy-sct`, `puriy-sct-testigo` | medio |
|
||
| 6.2 | **Descargas direccionadas por contenido** | nadie | store CAS de takana, `tejido` | bajo |
|
||
| 6.3 | **Archivo personal + RAG local** | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | `khipu`, `rag-motor`, `willay-rag` | medio |
|
||
| 6.4 | **Historial y perfil sobre `qullqa`** | nadie | `qullqa-core`, `qullqa-pozo` | medio |
|
||
| 6.5 | **Foco por `cortafuegos`, no por extensión** ✅ **cadena y navegador; falta quién lo aplica** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `nftables` (nuevo en el corpus) | bajo |
|
||
| 6.6 | **Medios por fuera del navegador** | extensiones sueltas; de fábrica no | `foreign-ytdlp`, `-platform`, `-dlna` | bajo |
|
||
| 6.7 | **IA local en la barra lateral** | Chrome/Edge son nube; Zen no tiene | `rimay`, `iniy` | bajo |
|
||
| 6.8 | **Proxy por contenedor** ✅ **v0.5** | nadie de fábrica | — (API de Firefox) | bajo |
|
||
| 6.9 | **Torrent adentro** | **Vivaldi lo trae; Brave tuvo WebTorrent** | `shared/foreign-torrent` (SDD propio, sobre librqbit) | bajo |
|
||
|
||
### 6.1 `sct` es la joya
|
||
|
||
BLAKE3 a todo lo que se ejecuta, y **negarse a correr código que ningún testigo vio nunca**. Es lo
|
||
más cerca que hay de «Certificate Transparency, pero para el JavaScript que te corre en la cara», y
|
||
no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser.
|
||
|
||
En tawasuyu ya está escrito y —dice su README— la lógica es **agnóstica del transporte** y está
|
||
certificada sin red; `puriy-sct-testigo` es sólo el cable HTTP.
|
||
|
||
- **v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo.** La extensión intercepta el
|
||
cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa al **host** (§7), que hashea y
|
||
decide. Decía «consulta al testigo antes de dejarla pasar», y las dos mitades había que ajustarlas:
|
||
el cable del testigo es un POST con **postcard**, así que un JS no puede ser su cliente (§7.quater);
|
||
y v1 **observa y avisa, no bloquea** — que es lo que `puriy-sct` dice de su propia v1, y además
|
||
bloquear cada script contra un viaje entre procesos no es «más seguro», es un navegador que nadie
|
||
usa. Medida de punta a punta por `scripts/test-atuq-sct.py`, con servidor HTTP real, seis cargas de
|
||
página y dos controles.
|
||
- **v2, forma fuerte:** gancho en el script loader de Gecko. Eso sí es parche de árbol ⇒ §2.bis ⇒
|
||
después de la toolchain del §3. Es también lo único que ve los scripts **inline** —
|
||
`filterResponseData` entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML— y lo único
|
||
que puede negarse a ejecutar.
|
||
|
||
#### 6.1.bis Lo que costó armarla, que es lo que no se puede deducir (2026-09-10)
|
||
|
||
Cinco cosas se midieron en vez de suponerse, y cada una tenía su forma de fallar en silencio:
|
||
|
||
| Qué | Cómo falla si se supone mal |
|
||
|---|---|
|
||
| El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, no en el appdir (`XRESysNativeManifests` es un `/usr/lib/mozilla` **compilado**) | el puerto se cierra «sin error y sin mensaje» |
|
||
| **El manifiesto NO puede llevar argumentos** — `NativeMessaging.sys.mjs`: `command = manifest.path`, y los únicos argumentos son `[ruta-del-manifiesto, id]` | `puriy-costura` sin `--state` corre en memoria ⇒ cada arranque vuelve a «aprendiendo» y **nada alerta nunca**: la función existe, no falla, y no protege |
|
||
| `filterResponseData` + el permiso `webRequestFilterResponse` **están** en nuestro `omni.ja` | se habría escrito la extensión contra una API que el build no tiene |
|
||
| Los `inline` no se ven por esta vía | se habría prometido cobertura total teniendo la mitad |
|
||
| **Una carga puede producir dos peticiones del mismo documento, y una llega con `tabId = -1`** | agrupando por `(tabId, documento)` esa carga contaba **dos visitas** |
|
||
|
||
La última es la que más importa y la que menos se veía. Con las visitas infladas, un origen **se
|
||
estabiliza 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. Se agrupa por documento —lo que hace que dos
|
||
pestañas con la misma url cuenten UNA, que es el error seguro: tarda más en proteger, no alerta de
|
||
más— y el guardián vigila el invariante «una carga, una visita», así que si vuelve, falla ruidoso.
|
||
|
||
**Y el aviso se mide, no se supone.** La extensión relee la insignia con `getBadgeText` después de
|
||
ponerla y lo dice, así que el guardián exige insignia vacía en las cinco cargas sin novedad y `"1"` en
|
||
la del script cambiado. Es la única parte de toda la cadena que el usuario llega a ver: dejarla como
|
||
«se llamó a la API» sería dejar sin medir justo el final.
|
||
|
||
**Dos límites escritos donde no se pueden no leer** (el propio `fondo.js`, el `lib.rs` del host y los
|
||
dos LEEME): se hashea el TEXTO ya decodificado, que vale para comparar dos cargas nuestras y **no** es
|
||
comparable con el hash de los bytes servidos que publique un tercero; y el aviso es pasivo (insignia,
|
||
no modal) porque un modal por despliegue entrena a cerrarlo sin leer.
|
||
|
||
### 6.2 Una descarga con identidad
|
||
|
||
Lo bajado no es un archivo en `~/Downloads`: es un objeto BLAKE3 en el store — deduplicado,
|
||
verificable, y re-compartible por `tejido`. El torrent (6.9) alimenta lo mismo. **Ningún navegador
|
||
trata una descarga como algo con identidad**; para todos es un blob con nombre, y por eso lo bajás
|
||
dos veces y no lo sabés.
|
||
|
||
Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en
|
||
una carpeta.
|
||
|
||
#### 6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida
|
||
|
||
`cas.ingest` en el host + la extensión `descargas@atuq.tawasuyu`, que observa las descargas
|
||
TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el
|
||
tamaño, con qué nombre apareció la primera vez y **cuántas veces se bajó ese mismo contenido**.
|
||
|
||
1ª descarga contenido A como `uno.bin` ⇒ dedup=false, veces=1
|
||
2ª descarga contenido A como `dos.bin` ⇒ dedup=TRUE, veces=2, UN solo blob en disco
|
||
|
||
**No es un almacén nuevo**: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
|
||
`tejido` — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por
|
||
acá queda donde los otros lo van a buscar, y el `<hex>` del CAS **es** el `expected_hash` de un `.swm`.
|
||
|
||
**Tres cosas que este trabajo dejó, y ninguna es la feature:**
|
||
|
||
1. **`arje-cas` gana ingesta en streaming.** `store` toma `&[u8]`, así que ingerir un fichero obliga a
|
||
leerlo entero: para los binarios de arje da igual, para lo que baja un navegador no —una ISO de
|
||
4 GiB serían 4 GiB de `Vec`—. `almacenar_fichero_en` hace UNA pasada (lee, hashea y escribe a la
|
||
vez, a un temporal con el PID, y recién entonces renombra al `<hex>`) y devuelve
|
||
`(hash, bytes, ya_estaba)`: ese `ya_estaba` **es** la deduplicación vista desde afuera.
|
||
2. ⚠ **La raíz del CAS de descargas no es la del sistema, y es por una pérdida de datos medida.**
|
||
`arje_cas::gc` borra todo blob que no esté en el set `reachable` de su llamador, y el único
|
||
llamador que existe hoy —`arje-brain::introspect`, `IntrospectRequest::GcCas`— lo arma con la
|
||
cadena de audit + las raíces vivas del grafo + lo que le pase el caller. **Una descarga del usuario
|
||
no está en ninguno de esos conjuntos: el primer `GcCas` se la llevaría, en silencio.** Por eso van
|
||
a `<estado>/descargas-cas`: mismo formato (mover un objeto al CAS del sistema es un `rename`),
|
||
fuera del alcance del GC de otro. Lo que se pierde, dicho: `tejido` sirve el CAS por defecto, así
|
||
que compartir una descarga hoy exige apuntarlo a esta raíz (`ENTE_CAS_ROOT`, la perilla que su
|
||
propio código documenta) hasta que el índice de descargas sea una raíz que el GC respete.
|
||
3. **No se toca el fichero del usuario.** Se copia al CAS y se deja donde estaba. Borrar o mover lo
|
||
que alguien acaba de bajar —aunque «ya esté en el CAS»— no es una decisión que le toque a un
|
||
navegador tomar por su cuenta, y el guardián lo comprueba byte a byte.
|
||
|
||
El índice va en **JSON** y no en postcard a propósito: es la lista de lo que el usuario bajó y tiene
|
||
que poder leerse con `cat` el día que el binario no arranque. El estado de `sct` es interno; esto es
|
||
suyo.
|
||
|
||
**Y ahí apareció el bug que valía 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; el de `sct` **revertía el índice de descargas** al valor que tenía cuando
|
||
arrancó. Los dos contestaban bien: el daño estaba sólo en el disco.
|
||
|
||
Se encontró porque el guardián lee el índice **en disco** en vez de creerle a la respuesta del host —
|
||
la respuesta decía `veces=2` y el fichero decía 1. Aseverar sobre lo que el proceso contestó habría
|
||
dejado pasar una pérdida de datos silenciosa. El arreglo es que cada proceso escriba **sólo la parte
|
||
que tocó**, y lo que eso NO cubre queda dicho: dos procesos que toquen la MISMA parte se seguirían
|
||
pisando, y ese día hace falta un dueño único del estado, no más banderas.
|
||
|
||
⚠ **Y el test de ese arreglo también costó, con una lección propia:** la primera versión abría los dos
|
||
hosts en SECUENCIA y **pasaba con el bug puesto**, porque el segundo cargaba de disco lo que el
|
||
primero acababa de escribir. El bug necesita que las vidas se SOLAPEN. Se comprobó rompiendo el
|
||
arreglo a propósito —con la rotura el test falla nombrando la causa, sin ella pasa—, que es la única
|
||
forma de saber si un guardián sirve.
|
||
|
||
`scripts/test-atuq-descargas.py` baja de verdad —`Content-Disposition: attachment`, no una llamada a
|
||
la API— dos veces, 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.
|
||
|
||
⚠ Y corre sobre **sway headless**, no sobre `--headless`, porque en `--headless` una descarga
|
||
terminada mata al navegador con SIGSEGV — con y sin nuestra extensión. Está medido y atribuido en
|
||
el §6.10.bis.
|
||
|
||
### 6.3 El archivo personal es la razón por la que alguien usaría `atuq`
|
||
|
||
Cada página visitada se congela en el CAS y entra al DAG de `khipu`; después le preguntás a tu
|
||
propio historial en lenguaje natural, **todo local**. Es la feature que más justifica el proyecto
|
||
entero, y sale de piezas que ya están escritas.
|
||
|
||
#### 6.3.bis La MITAD que se puede pagar hoy, y la que no (2026-09-10)
|
||
|
||
El §6.3 promete dos cosas y conviene separarlas, porque una está hecha y la otra depende de
|
||
infraestructura que la imagen todavía no lleva:
|
||
|
||
| mitad | estado |
|
||
|---|---|
|
||
| **congelar cada página leída y poder encontrarla** | ✅ HECHA — `archive.add` / `archive.search` + la extensión `archivo@atuq.tawasuyu` |
|
||
| **preguntarle al historial en lenguaje natural** | ❌ falta, y ya tiene forma: un motor `rag-motor::RagMotor`, como `willay-rag` |
|
||
|
||
Lo segundo no se «aproxima»: `willay-rag` —el motor del centro de eventos, que es el ejemplo a
|
||
copiar— necesita un **daemon de embeddings** (`rimay-verbo`) y un **backend LLM real** (`pluma-llm`),
|
||
y su `try_build()` devuelve `None` cuando falta cualquiera de los dos, dejando el panel «no
|
||
disponible». Enseñar una búsqueda literal diciendo que es la semántica sería el peor cambio posible,
|
||
así que el verbo se llama `search`, el aviso está arriba de la función en el código, y acá queda
|
||
escrito qué falta exactamente.
|
||
|
||
**Lo que sí quedó, y por qué vale:** la identidad de una página archivada es el **BLAKE3 de su HTML
|
||
ya ejecutado** —lo que la persona vio, no lo que el servidor mandó—, no su URL. De ahí sale todo:
|
||
volver a la misma página no la duplica (suma una visita), y **la misma URL con otro contenido es otra
|
||
página**, o sea que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
|
||
punteros se pudren; esto guarda lo leído.
|
||
|
||
**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 (guardar el HTML de un visor de PDF llena el archivo de cosas que después no se encuentran).
|
||
El descarte de lo privado se comprueba en el fondo y no en el guión de contenido porque `tabs.get()`
|
||
es lo único que sabe si la pestaña lo es; y si no se puede saber, **no se archiva**.
|
||
|
||
⚠ **El control negativo distingue quién protege, y la respuesta medida NO es la que uno supondría.**
|
||
`scripts/test-atuq-archivo.py --negative-control` abre la página en una ventana privada, exige que no
|
||
se archive nada, y dice por qué. Medido sobre `atuq b3:5cfe3221`:
|
||
|
||
✓ en ventana privada no se archivó nada — lo impidió el NAVEGADOR
|
||
(la extensión no llegó a correr en la ventana privada)
|
||
|
||
O sea que hoy **el que protege es Gecko**: las extensiones no corren en ventanas privadas salvo que se
|
||
las habilite. Nuestro `if (emisor.tab.incognito) return` **nunca se ejecuta** con la configuración
|
||
actual. Eso no lo vuelve código muerto: es exactamente lo que haría seguro habilitar
|
||
`private_browsing` en `ExtensionSettings` el día que haga falta —y sin él, habilitarlo archivaría en
|
||
silencio lo que alguien abrió en privado—. Lo que sí sería un error es haber escrito «la extensión no
|
||
archiva lo privado» sin saber cuál de las dos cosas estaba pasando.
|
||
|
||
**Dos techos con nombre**, no escondidos en un `truncate`: el índice guarda 4 KiB de texto por página
|
||
—es JSON para poder leerse con `cat`, y uno sin techo deja de poder— y el corte va por CARÁCTER, 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.
|
||
|
||
### 6.4 `qullqa` con su advertencia puesta
|
||
|
||
Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
|
||
eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de `qullqa` ya anotó y que
|
||
**hay que repetir acá, no esconder**: en una máquina con swap sin cifrar, el documento no aplica.
|
||
Se promete lo que se puede probar.
|
||
|
||
### 6.5 Foco por `cortafuegos` — la mitad del sistema, MEDIDA (2026-09-10)
|
||
|
||
**La primera corrección es a este propio documento.** La fila de la tabla dice «foco por
|
||
`cortafuegos`» y es fácil leerla como «bloquear sitios que distraen». **El cortafuegos de tawasuyu no
|
||
sabe de sitios**: su política de egress (`cortafuegos-core::Politica`) tiene exactamente una
|
||
dimensión —**qué cgroup puede salir**— y ninguna de destino; ni dominio, ni IP, ni puerto. Leído en
|
||
la fuente (`UnidadRed { nombre, cgroup_path, concesion }`), no supuesto. Así que el foco que estas
|
||
piezas permiten no es «Twitter no carga»: es **«el navegador no sale»**, con todo lo local
|
||
—incluido el archivo personal del §6.3 y el CAS del §6.2— funcionando igual. Que es, además, una
|
||
promesa más honesta: una lista de dominios se esquiva con un espejo, y un cgroup sin egress no.
|
||
|
||
**Y la diferencia con una extensión no es de grado.** Un bloqueador de extensión lo apaga en dos
|
||
clics la misma persona que quería no distraerse; un reglaset `nft` que aplicó root no lo toca el
|
||
navegador ni sus extensiones. Por eso la fila decía «nadie lo tiene»: nadie es dueño del navegador
|
||
*y* del sistema.
|
||
|
||
**Lo que hizo falta traer.** `nftables` estaba en el grafo como `wanted` y nadie había puesto la
|
||
cadena: entran `recipes/libmnl.toml`, `recipes/libnftnl.toml` y `recipes/nftables.toml` (1.1.6, la
|
||
versión contra la que el SDD del cortafuegos validó `socket cgroupv2` en metal). Dos cosas venían de
|
||
fábrica y están en los comentarios de la receta: nftables **no reproduce** —`MAKE_STAMP` es
|
||
`$(shell date +%s)` horneado en el binario, la familia del `BuildID` de waterfox— y su
|
||
`config.status` trae un bashismo que busybox rechaza. Las dos se arreglan en el parche, y el control
|
||
existe: con el sello vivo, dos builds de la misma fuente **divergen** en `libnftables.so`
|
||
(`why-differs`: `.data`, `.text`); con el parche, `verificar-repro.sh` da REPRODUCE.
|
||
|
||
**La medición, que es la unidad de trabajo de verdad** (`scripts/test-foco-egress.sh`, lanzado con
|
||
`scripts/foco-egress-remoto.sh`):
|
||
|
||
```
|
||
nft: nftables v1.1.6 ← el NUESTRO, musl, desde el artefacto sellado
|
||
CONEXION linea-base exit=0 ← sin reglas: el harness mide algo
|
||
CONEXION control exit=0 ← con reglas: el cgroup permitido SÍ conecta
|
||
CONEXION foco exit=1 ← el cgroup del navegador NO
|
||
```
|
||
|
||
Tres medidas y no una: la **línea base** existe porque un harness roto («no conectó») se lee
|
||
exactamente igual que un foco que funciona, y el **control** porque un reglaset que niega de más no
|
||
se distingue de uno que niega bien. Con `--broken-rules` —que le abre egress también al cgroup en
|
||
foco— el guardián falla diciendo `el cgroup EN FOCO conectó igual`, y sale 1.
|
||
|
||
**Por qué no corre en el hub, y es una restricción real, no comodidad.** Crear un cgroup pide root:
|
||
medido, `mkdir /sys/fs/cgroup/...` da EPERM **incluso dentro de un userns con `--cap-add ALL`**,
|
||
porque el permiso se chequea contra la jerarquía real. Y `nft -c` **no es un chequeo de sintaxis**:
|
||
resuelve el path del cgroup contra la máquina viva y falla con `cgroupv2 path fails` si no existe. O
|
||
sea que ni siquiera *verificar* una política del cortafuegos se puede sin los cgroups puestos. Corre
|
||
en el LXC prestado, y todo va dentro de `unshare -m --propagation private -n`.
|
||
|
||
⚠ **Y ese `--propagation private` se pagó caro.** `/sys/fs/cgroup` está montado **shared**, así que
|
||
desmontar la COPIA de un `--rbind` **se propaga al montaje real**: dos corridas dejaron al worker
|
||
**sin `cgroup2` montado**, en silencio —los builds seguían— hasta que el guardián siguiente dijo «no
|
||
hay cgroup2 montado». Se remontó y la jerarquía volvió intacta (los cgroups viven en el kernel, no en
|
||
el montaje). Tres lecciones que quedan en el guión: los montajes van en un **mount namespace propio**
|
||
y se van solos; **nunca `umount -l`** antes de un `rm -rf` (un desmontaje perezoso desaparece de
|
||
`/proc/mounts` mientras el árbol sigue ahí, así que la guarda pasa y el borrado entra en el `/sys` de
|
||
la máquina); y antes de borrar, **preguntarle a `/proc/mounts`**.
|
||
|
||
⚠ **Tres falsos positivos más que cazó el propio harness, y todos se veían como éxito:**
|
||
|
||
1. un `gmp` **viejo** empaquetado por `ls store/*-gmp` (sólo `libgmp.a`) ⇒ `libnftables.so` no
|
||
relocaba y **ninguna** conexión salía. En el hub no se había notado porque ahí el lab tiene su
|
||
propia libgmp y la tapaba: el artefacto parecía autosuficiente y no lo era. Ahora los artefactos
|
||
se resuelven por `takana hash` (vigente), y el guardián corre `nft --version` **antes** de todo;
|
||
2. `/sbin` fuera del `PATH` del chroot ⇒ no había `ip`, el loopback quedaba caído y todo fallaba;
|
||
3. y el mejor: **un comentario que se ejecutó**. El guión interior se escribía con un heredoc sin
|
||
comillas, y un comentario que mencionaba `` `ip` `` y `` `ifconfig` `` entre backticks los corrió
|
||
**en el host** y pegó la salida —con las IPs de la máquina— dentro del guión generado. Ahora el
|
||
heredoc va entrecomillado y los nombres entran por entorno.
|
||
|
||
**Lo que falta para que esto sea un botón, y por qué no se inventó de paso.** Dos piezas, ninguna del
|
||
navegador:
|
||
|
||
- **quién pone a `atuq` en un cgroup.** Hoy nadie: los cgroups de la distro los crea arje para sus
|
||
servicios, y una app de escritorio la lanza el usuario. Hace falta delegación de un subárbol a la
|
||
sesión, que es decisión de arje (SDD 10), no de este documento;
|
||
- **quién aplica la política.** `cortafuegos apply` pide root. Un demonio que lo haga a pedido del
|
||
navegador es superficie nueva: se decide, no se agrega de paso.
|
||
|
||
Mientras eso no exista, lo que `atuq` puede tener —y tiene, §6.5.bis— es la mitad honesta: **ver** el
|
||
estado del foco y **no poder apagarlo**.
|
||
|
||
#### 6.5.bis La mitad del navegador: `focus.state`, y la asimetría a propósito (2026-09-10)
|
||
|
||
La extensión `foco` **sólo lee**. Pregunta `focus.state` al host cada minuto —el estado lo cambia root
|
||
por fuera, no hay evento al que suscribirse— y pinta tres cosas: `foco`, nada, o **`?`**. El tercer
|
||
caso es el que importa: el host contesta `unknown` cuando nadie escribió el estado o cuando el
|
||
contenido no se entiende, y **eso no se redondea a `off`**; una insignia que dijera «sin foco» sin
|
||
haber mirado afirmaría un hecho que no tiene.
|
||
|
||
**Y no hay verbo para apagarlo.** `focus.stop` cae en «verbo desconocido», igual que `focus.off` y
|
||
`focus.start`. No es una omisión pendiente: es la propiedad entera. Si el navegador pudiera levantar
|
||
el foco, esto valdría exactamente lo que vale un bloqueador de extensión —dos clics— que es el
|
||
problema que el §6.5 venía a resolver. Encenderlo tampoco está: aplicar un reglaset pide root y este
|
||
proceso corre como el usuario. Hay un test en tawasuyu que lo fija por nombre, y se puso rojo a
|
||
propósito agregando un `focus.stop` «por comodidad».
|
||
|
||
El estado vive en `/etc/takana/focus` (lo escribe root junto con el reglaset). `scripts/test-atuq-foco.py`
|
||
corre **el path de producción, no la escotilla de pruebas** —una escotilla mide el código, no el
|
||
contrato con la imagen— en tres sesiones del navegador, y en cada una comprueba tres cosas: qué dijo
|
||
la extensión, qué pintó, y que el fichero **quedó igual** después de la sesión. Eso último es la
|
||
promesa del §6.5 medida del lado del navegador. Verificado rompiéndolo: con una extensión parcheada
|
||
que pinta «foco» pase lo que pase (dentro de una COPIA del artefacto, vía `ATUQ_DIR`), el guardián
|
||
falla nombrando la insignia.
|
||
|
||
```
|
||
-- fichero de foco: on → ESTADO on · INSIGNIA "foco" · quedó on
|
||
-- fichero de foco: off → ESTADO off · INSIGNIA "" · quedó off
|
||
-- fichero de foco: (no existe) → ESTADO unknown · INSIGNIA "?" · siguió ausente
|
||
```
|
||
|
||
### 6.6 Medios fuera del navegador — HECHO (2026-09-10)
|
||
|
||
Una navegación de PRIMER NIVEL a un medio (`Content-Type: video/*` o `audio/*`) se cancela y la URL se
|
||
la lleva el reproductor de la distro. `mpv` ya viaja en las cuatro imágenes de escritorio, así que
|
||
esto no agrega ni una receta: es cablear lo que ya estaba.
|
||
|
||
**La regla es estrecha a propósito.** Un `<video>` embebido es parte de la página y sacarlo de ahí
|
||
rompería el sitio que lo puso; lo que se cambia es el caso en que el navegador no estaba pintando una
|
||
página sino haciendo de reproductor —seguiste un enlace a un `.mp4`—. Las reglas anchas en el camino
|
||
de cada petición son las que terminan rompiendo la web de alguien.
|
||
|
||
**La seguridad del verbo es que el binario NO viene del cable**: es una constante del host
|
||
(`/usr/bin/mpv`). Si el programa viajara en el mensaje, esto dejaría de ser «abrir un vídeo» y sería
|
||
un lanzador de procesos arbitrarios manejado, al final de la cadena, por una página web. Sólo viaja la
|
||
URL, sólo `http(s)`, y detrás de un `--`.
|
||
|
||
**Fail-open, y es la promesa que el control negativo prueba.** 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
|
||
si el puerto está vivo, y si el host contesta que no pudo, la URL **vuelve al navegador** (con una
|
||
marca para no entrar en bucle). Medido:
|
||
|
||
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 el que se consigue si uno
|
||
confía en que salió bien.
|
||
|
||
**⚠ Y el bug que esto destapó vale más que la función.** La primera corrida hizo todo bien —detectó el
|
||
medio, canceló, el host contestó con el pid— y después:
|
||
|
||
DESCONECTADO Native application tried to send a message of 546281442 bytes,
|
||
which exceeds the limit of 1048576 bytes.
|
||
|
||
**Un hijo hereda los descriptores del padre, y los del padre SON la tubería de native messaging.** El
|
||
reproductor escribía su salida ahí y el navegador la leía como un marco. Tres redirecciones, cada una
|
||
contra un fallo distinto: `stdin` a null (`mpv` lee stdin para sus comandos: heredándolo **se come los
|
||
mensajes del navegador**), `stdout` al **`stderr` del host** —no a `/dev/null`: apagarlo arreglaría el
|
||
bug y se llevaría puesto el diagnóstico, y Gecko manda ese `stderr` al log del navegador, que es donde
|
||
uno mira cuando «el vídeo no abrió»— y `stderr` heredado por lo mismo.
|
||
|
||
El test que lo fija **arranca el binario, no la lib**, y ésa es la parte reutilizable: en proceso, el
|
||
`stdout` del host es el del arnés y la contaminación no se ve — el test habría pasado con el bug
|
||
puesto. Se comprobó rompiéndolo a propósito.
|
||
|
||
⚠ Y una nota del arnés que ya apareció dos veces: la URL no se pasa por la línea de comandos, porque
|
||
una petición del ARRANQUE compite con la inicialización de la extensión. El guardián navega a una
|
||
página que redirige a los tres segundos — que además es lo que hace una persona: seguir un enlace.
|
||
|
||
### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)
|
||
|
||
**Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único
|
||
de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor
|
||
—Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
|
||
|
||
Tres piezas, en tres ficheros, y **ninguna alcanza sola**:
|
||
|
||
| Pieza | Dónde | Qué aporta |
|
||
|---|---|---|
|
||
| `privacy.userContext.enabled` | `atuq.cfg` | prende los contenedores, que Firefox trae apagados |
|
||
| `Containers.Default` | `distribution/policies.json` | los CREA — es el único mecanismo que los pone en un perfil NUEVO |
|
||
| `3rdparty.Extensions` | `distribution/policies.json` | la config de fábrica, que la extensión lee como `storage.managed` |
|
||
| `proxy.onRequest` | `extensions/proxy/fondo.js` | lo único que ve el `cookieStoreId` de cada petición |
|
||
|
||
**Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea**, que es la regla que
|
||
dejó el §2.sexies: `Containers` y `3rdparty` están en el `policies-schema.json` de `browser/omni.ja`,
|
||
`cookieStoreId` en el `schemas/proxy.json` de `omni.ja`, y `storage.managed` lee
|
||
`Services.policies.getExtensionPolicy(id)` en `ext-storage.js`. Ninguna de las cuatro se leyó de la
|
||
documentación de Mozilla: se leyeron de **nuestro** build, que es el que las tiene que traer.
|
||
|
||
**Tres decisiones de diseño que valen más que el código:**
|
||
|
||
1. **La configuración se indexa por NOMBRE de contenedor, no por `cookieStoreId`.** El id
|
||
(`firefox-container-3`) depende del ORDEN en que se crearon, así que la misma configuración
|
||
aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de significado
|
||
entre máquinas no sirve para configurar una distro.
|
||
2. **Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar —entrada
|
||
inválida, API que falla, mapa a medio construir— **no sale directo**: va a un destino cerrado y
|
||
el navegador muestra el error. Salir directo sería una fuga silenciosa, y es la misma familia
|
||
que el artefacto vacío de la regla 3: el fallo que llega hasta el final diciendo que todo fue
|
||
bien. Un usuario que pidió que «Banco» no salga por su línea tiene que ver un error, no navegar.
|
||
3. **`proxyDNS` viene PRENDIDO.** Sin él, Gecko resuelve el nombre por su cuenta antes de hablar
|
||
con el proxy: la consulta DNS sale justo por la línea que se quería evitar. Es la fuga clásica de
|
||
esta configuración, así que el default es el seguro y hay que apagarlo a mano.
|
||
|
||
**Y lo que NO promete está en la propia página de opciones, arriba de todo y no en un pie**:
|
||
separación de tráfico, no anonimato; no toca la huella del navegador; para anonimato, Tor Browser.
|
||
Es el §4 cumplido en el sitio donde el usuario lo va a leer.
|
||
|
||
#### Lo que quedó PROBADO, y con qué
|
||
|
||
Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`:
|
||
|
||
| Afirmación | Prueba |
|
||
|---|---|
|
||
| La política CREA los cuatro contenedores | captura de `about:preferences#containers` con Personal/Trabajo/Banco/Compras y sus iconos, más el `containers.json` del perfil |
|
||
| Las dos extensiones se INSTALAN | `extensions.json` del perfil nombra `inicio@atuq.tawasuyu` y `proxy@atuq.tawasuyu` |
|
||
| El ruteo se arma contra los contenedores reales | `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — o sea que leyó la config de fábrica por `storage.managed` Y la casó con los contenedores que creó la política |
|
||
| **Una petición hecha en «Banco» SALE por el proxy de «Banco»** | `scripts/test-atuq-ruteo.py`: dos pestañas piden la misma url; al destino llega `GET /directo` y al puerto del proxy un saludo **SOCKS5 (`\x05\x01\x00`)**, y la de «Banco» no aparece nunca en el destino. Con `--negative-control` (sin proxy configurado) las dos van directas y al proxy no llama nadie |
|
||
| Los guardianes del cruce política↔XPI FALLAN cuando deben | `scripts/test-atuq-politica.py`: 5 formas de romperlo, las 5 matan el build, y el control con la política intacta pasa |
|
||
| La capa sobrevive al cambio de BASE | rehecho sobre el `firefox` con RLBox (`b3:8116bdec`, 2026-09-06): `atuq` sella en 2 s como `b3:f2960991`, su `libxul.so` trae **634 símbolos que se LLAMAN `w2c_*`** donde la base anterior traía 0, y los cuatro contenedores y las dos extensiones siguen apareciendo en un arranque real |
|
||
|
||
⚠ **Ese 634 hay que contarlo anclado, y la primera vez se contó mal.** `llvm-nm --defined-only \| grep -c w2c_` da **1547** sobre el mismo fichero, y los 913 de más son nombres C++ mangleados que contienen `w2c_` en el MEDIO (`_ZN5rlbox...PK16w2c_mem_capacity...`), no símbolos de la jaula. El patrón que cuenta lo que dice contar es `grep -c ' w2c_'` —o el equivalente `awk '{print $3}' \| grep -c '^w2c_'`—, y da 634, que es lo que mide el guardián de `recipes/firefox.toml`. La conclusión no cambia (la base anterior daba **0** con cualquiera de los dos patrones); lo que cambia es que un número sin su patrón no es una medición. Muestra de lo que sí es un símbolo de la jaula: `w2c_rlbox_0x5F_cxa_atexit_0`.
|
||
|
||
**Y de paso quedó comprobado que la dep SÍ se sigue**, que era la promesa del §2 y no una que convenga
|
||
creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` copiado mueve el hash de
|
||
`atuq` (`f2960991` → `60ade76a`), mientras que con el `firefox` idéntico al del corpus da el mismo
|
||
hash. O sea que un `atuq` viejo **no** puede quedarse tapando un motor nuevo — que es justo la forma
|
||
que tendría acá el cache-hit que congela regresiones.
|
||
|
||
**Cómo se cerró lo que faltaba (2026-09-06).** Durante unas horas esta sección decía que probar el
|
||
paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe
|
||
igual, porque el camino era otro.
|
||
|
||
Las tres puertas, que conviene NO volver a golpear:
|
||
|
||
1. **Desde la línea de comandos no hay flag** para el `userContextId` de la pestaña inicial. Y un
|
||
`moz-extension://` tampoco se abre así: el manejador intenta resolverlo como fichero y muere con
|
||
`NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su lugar.
|
||
2. **Marionette está en el artefacto y responde** —se le habló con un cliente propio de 60 líneas,
|
||
el protocolo es `<longitud>:<json>`—, pero abrir una pestaña de contenedor pide contexto chrome,
|
||
y eso exige `-remote-allow-system-access`; en la jaula no lo tomó ni por bandera, ni con
|
||
`--remote-debugging-port`, ni por `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`, que es lo que
|
||
`RemoteAgent.sys.mjs` lee en su constructor. Cabo suelto medido, no suposición.
|
||
3. **Desde la extensión, `tabs.create({cookieStoreId})` exige el permiso `cookies`**
|
||
(`ext-tabs-base.js:getUserContextIdForCookieStoreId`). Dárselo a la extensión del proxy para que
|
||
pueda probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del
|
||
test. Sigue sin dárselo.
|
||
|
||
**La cuarta puerta estaba abierta: el fichero de sesión.** `sessionstore` guarda el `userContextId`
|
||
de cada pestaña y Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\0` + tamaño +
|
||
un bloque LZ4, y **un bloque de sólo literales es LZ4 válido**: veinte líneas, sin depender de
|
||
ninguna librería. Con eso se abren dos pestañas —misma url, distinto contenedor— sin pedirle nada al
|
||
navegador.
|
||
|
||
Y la medición no le pregunta nada tampoco: **se le ponen dos oídos en la red y se mira a cuál llama.**
|
||
El destino contesta un HTTP 200; el «proxy» no habla SOCKS, sólo anota quién lo saludó. Que al
|
||
puerto del proxy llegue un `\x05\x01\x00` es la prueba: ese saludo sólo aparece si Gecko decidió
|
||
hablar con un proxy para esa petición, y el único que se lo pudo indicar es nuestro
|
||
`proxy.onRequest` mirando el `cookieStoreId`.
|
||
|
||
Tres detalles sin los cuales la prueba mide un silencio y se lee como un fallo:
|
||
`browser.sessionstore.restore_on_demand=false` (si no, las pestañas restauradas no piden nada hasta
|
||
que alguien las mira), `network.proxy.allow_hijacking_localhost=true` (Gecko saltea el proxy para
|
||
localhost, y la petición de «Banco» iría directa haciendo parecer culpable a la extensión), y
|
||
`triggeringPrincipal_base64: vQ==` en cada entrada de sesión (sin principal la pestaña se restaura
|
||
pero no navega).
|
||
|
||
**Y la prueba trae su propio control negativo**, porque es lo que este repo se exige desde hoy: con
|
||
`--negative-control` no se configura el proxy y se exige el resultado CONTRARIO —las dos pestañas
|
||
directas, nadie llamando al proxy—. La única diferencia entre las dos corridas es una línea de
|
||
configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto
|
||
ciega se vería idéntica a una que funciona.
|
||
|
||
### 6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso
|
||
|
||
Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que **lo que baja entra al
|
||
CAS**: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del
|
||
navegador (§6.2) o un `.swm` de takana. Ésa, y no «tener torrent», 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é un proceso aparte y no un verbo más del host — dos razones, y la primera decide:**
|
||
|
||
1. **Una descarga tiene que sobrevivir al navegador.** Gecko lanza un host de native messaging **por
|
||
puerto** y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente
|
||
BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la pestaña.
|
||
2. **El precio.** `librqbit` + tokio + rustls son **226 crates**: ese host —952 K, compartido por las
|
||
cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un
|
||
cuarto de hora.
|
||
|
||
⚠ **Y 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; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la
|
||
decisión no fue «no se puede», fue **«no ahí»**. Medir primero es lo que separa una decisión de
|
||
arquitectura de una excusa.
|
||
|
||
**Perezoso en los dos sentidos**, que es lo que pidió el operador:
|
||
|
||
| | qué significa |
|
||
|---|---|
|
||
| nadie lo arranca | **no hay servicio en la imagen**: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar |
|
||
| se va solo | cuando no queda nada activo por `inactividad_seg` (300 s por defecto; `0` = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre |
|
||
|
||
⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con
|
||
él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca
|
||
igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una
|
||
degradación silenciosa de su única promesa.
|
||
|
||
**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`,
|
||
`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un
|
||
error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en
|
||
cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos
|
||
raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**.
|
||
|
||
**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`):
|
||
|
||
1. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA
|
||
esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la
|
||
cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma
|
||
en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que
|
||
nadie revisa;
|
||
2. **no pasa por el despacho de protocolos de Gecko** — un clic en un `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, que es la que el handler abriría, descubriendo
|
||
su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil).
|
||
|
||
Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y
|
||
nombrando lo que falta** — no finge haberlo tomado.
|
||
|
||
⚠ **Y el bug que encontró MIRAR LA MÁQUINA, no un test.** Después de las pruebas, `ps` mostraba un
|
||
daemon con **23 minutos de vida** y `idle_seconds = 300`. El bucle hacía
|
||
`let Ok(g) = gestor() else { continue }`: si la sesión de torrent no se podía crear, el `continue`
|
||
saltaba la evaluación de «¿sobro?» y el daemon quedaba **vivo para siempre** — lo contrario de
|
||
perezoso, que es su única promesa. Los tests cubrían la decisión como función PURA, y esa función
|
||
estaba bien: **el que no llegaba a llamarla era el bucle**. Una decisión correcta que nadie toma se ve
|
||
igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el
|
||
PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el `continue`.
|
||
|
||
⚠ **La pereza tiene un residuo, y el guardián lo daba por bueno (2026-09-10).** El daemon se va
|
||
«tras 300 s **sin nada**», y un torrent sin enjambre nunca termina ⇒ nunca está sin nada: se queda,
|
||
que es lo correcto para un cliente de torrent. Pero el guardián cerraba el sandbox y se iba, y
|
||
**quedaba vivo el daemon Y el `bwrap` interno**, sosteniendo abiertos overlays ya borrados —
|
||
encontrado con `ps` sobre la máquina, no por un test. La corrección tiene dos mitades y ninguna
|
||
sobra:
|
||
|
||
1. el guión de adentro **despide al daemon** con `puriy-costura-torrent stop` — el propio verbo del
|
||
producto, así que de paso se ejerce;
|
||
2. el guardián **cuenta los daemons antes y después** (`ps -C`, nunca `pkill -f`, que empareja la
|
||
línea de comandos y se lleva la shell del que corre el test) y **falla nombrando el PID** si quedó
|
||
alguno; y barre lo propio en el `finally`, para que un test rojo tampoco deje basura.
|
||
|
||
Y el orden importa: la huella del socket se anota **adentro y antes** del `stop`, porque al irse el
|
||
daemon lo borra — mirarlo desde el hub después mediría el reloj, no el hecho. Comprobado en los dos
|
||
sentidos, quitando el `stop` en una copia fuera del repo: sin él el guardián decía ✓ igual y dejaba
|
||
un daemon suelto; con la aserción nueva sale
|
||
`✗ quedó un daemon vivo tras cerrar el sandbox: PID(s) [20157]`. La primera hipótesis —que sin `stop`
|
||
el guardián se COLGARÍA, porque `bwrap --unshare-pid` espera a su namespace— **era falsa y la
|
||
medición la descartó**: el `bwrap` externo vuelve, y el que se queda esperando es el interno. El tope
|
||
de 180 s quedó igual, pero por lo que es: seguro contra un cuelgue, no el que detecta la fuga.
|
||
|
||
⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un
|
||
sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del
|
||
daemon, al pie, para que nadie lo lea como «probado de punta a punta».
|
||
|
||
### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07)
|
||
|
||
Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf`
|
||
puede cubrir**. La jerarquía de lo que se ve:
|
||
|
||
NEEDED del ejecutable → lo ve `test-atuq-rootfs.py` y lo ve `vigia-sonames.py`
|
||
NEEDED de un .so dlopeado → lo ven los dos, si el fichero está en el artefacto
|
||
dlopen("libfoo.so.1") LITERAL → NO LO VE NINGUNO
|
||
|
||
Y un navegador vive del tercer escalón: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una
|
||
docena más **por nombre**, y cuando no están **no falla: apaga la función y sigue**. No hay línea
|
||
roja; hay una función que nadie ofrece y nadie reclama. Es la misma forma que ya nos costó caro con
|
||
OBS (`dlopen("libGL.so.1")` desde su glad).
|
||
|
||
`scripts/test-atuq-rootfs.py --dlopen` busca esas cadenas y las cruza contra el rootfs. **Es un
|
||
heurístico y se declara como tal** —una cadena no prueba un `dlopen`, y su ausencia no prueba que no
|
||
lo haya—, así que no falla nunca: imprime un mapa triado. Lo que sí se puede afirmar es lo
|
||
contrario, que es lo útil: **si la cadena está y el fichero no, esa función no existe en esta
|
||
imagen.**
|
||
|
||
De 85 cadenas con forma de soname, **47 no tenían quién las provea**. Triadas (y con dos filas
|
||
corregidas el mismo día — ver §6.11, que las midió en vez de deducirlas; hoy el mapa da **7 huecos**):
|
||
|
||
| Qué se pierde | Estado |
|
||
|---|---|
|
||
| **Códecs del sistema (H.264/AAC)** | ❌ **ERA FALSO** — `recipes/ffmpeg.toml` existe, está sellada y ya viajaba en los cuatro escritorios por `mpv`. Corregido en §6.11: **reproduce** |
|
||
| **Notificaciones web** (`libnotify`) | ✅ **CERRADO el 2026-09-07**: `recipes/libnotify.toml`, 0.8.8, compartida. Es el primer hueco que este mapa encontró Y cerró |
|
||
| **Llavero** (`libsecret`) | hueco — pero **la receta ya existe** en `incoming-gnome` |
|
||
| **Sonidos del sistema** (`libcanberra`) | hueco — receta en `incoming-kde` |
|
||
| **WebGPU / vulkan** (`libvulkan`) | hueco — receta `vulkan-loader` en `incoming-kde` |
|
||
| **Vídeo por hardware** (VA-API) | hueco, pero **no por falta de receta**: `libva` está sellada y en las cuatro imágenes. Falta el driver — las tres mesa van con `-Dgallium-va=disabled` y `-Dvideo-codecs=` vacío ⇒ ni un `*_drv_video.so` |
|
||
| **Lectura en voz alta** (`libspeechd`) | hueco — sin receta |
|
||
| `libGL` | **decisión**: la distro es Wayland-only sin GLX y Firefox va por EGL |
|
||
| `libcurl` | **decisión**: sólo lo usa `pingsender`, que manda telemetría — apagada |
|
||
| Kerberos, menú global, ML | fuera de alcance |
|
||
| `libdl/libpthread/librt/libudev.so.0/libfreebl3` | ruido: musl los funde, o el soname es viejo |
|
||
|
||
**El primero ya está cerrado.** `libnotify` no tenía receta y ahora la tiene: es la prueba de que el
|
||
mapa sirve para algo más que mirarlo. ⚠ Con una advertencia que va escrita en la propia receta:
|
||
`libnotify` no trae un daemon, manda `org.freedesktop.Notifications` por D-Bus ⇒ tenerla resuelve la
|
||
mitad —que firefox la encuentre— y la otra mitad es que en la imagen haya alguien escuchando ese
|
||
nombre. Nadie debería leer «notificaciones arregladas» y esperar un globo en un QEMU pelado.
|
||
|
||
**⚠ CORRECCIÓN, mismo día: escribí que tres de los siete eran «baratos, promoción y no autoría»
|
||
—`libsecret`, `libcanberra`, `vulkan-loader`— y es FALSO.** Lo comprobé antes de declararlos en los
|
||
perfiles, y en los tres casos falta la otra mitad:
|
||
|
||
| «barata» | por qué NO alcanza con declararla |
|
||
|---|---|
|
||
| `vulkan-loader` | **las tres recetas de mesa construyen con `-Dvulkan-drivers=` VACÍO** ⇒ el loader no encontraría un solo dispositivo. Un cargador sin ICD no es WebGPU |
|
||
| `libsecret` | no hay receta de `gnome-keyring` ni de ningún servicio de secretos: firefox hablaría a `org.freedesktop.secrets` y no habría nadie |
|
||
| `libcanberra` | no hay receta de `sound-theme-freedesktop`: la librería carga y no suena nada |
|
||
|
||
Declararlas habría subido el número de paquetes de la imagen sin encender una sola función. **Una
|
||
librería es media función; la otra mitad es quien la atiende**, y eso vale para todas las de esta
|
||
tabla. Quedan como huecos, no como promociones pendientes.
|
||
|
||
⚠ **Y esta frase también se cayó el mismo día.** Decía que de los otros cuatro «el más caro para el
|
||
usuario es `ffmpeg`: sin él, un sitio que sirva H.264 no reproduce» — con lo cual el hueco más caro
|
||
del mapa **no existía**. Ver §6.11: la receta estaba, faltaba declararla en el runner, y H.264 (y
|
||
AAC, VP9, Opus, AV1, MP3, FLAC) reproducen. De los cuatro «sin receta» quedan tres, y ninguno es un
|
||
códec: lectura en voz alta (`libspeechd`), el driver de VA-API, y `libpci` —que sólo lo usa
|
||
`glxtest`, o sea irrelevante sin GLX.
|
||
|
||
**Y la misma vara aplicada a `libnotify`, que sí se declaró:** su otra mitad es un daemon que
|
||
escuche `org.freedesktop.Notifications`. Medido perfil por perfil:
|
||
|
||
escritorio-kde plasma-workspace ✓ completa
|
||
escritorio-gnome gnome-shell ✓ completa
|
||
escritorio-cosmic cosmic-notifications ✓ completa
|
||
escritorio-sway dunst 1.12.2 ✓ completa — CERRADA el 2026-09-07
|
||
|
||
La de sway se cerró el mismo día con `recipes/incoming-wlr/dunst.toml`, y la trampa del homónimo
|
||
—**el nombre `mako` YA ESTÁ OCUPADO en el corpus** por el motor de plantillas de Python de Mesa— se
|
||
esquivó eligiendo dunst, que además habla D-Bus por GDBus y no por `sd-bus`, o sea sin receta nueva
|
||
de `basu`. La prueba no es que selle: `scripts/wlr/dunst-headless.sh` levanta sway headless y un bus
|
||
de sesión, manda un `notify-send` y **deja que el bus active el daemon solo** —el mismo camino que
|
||
recorre una página web en `atuq`—, y mide un diff de píxeles antes/después: 14.832 cambiados con el
|
||
`.service` puesto, 0 sin él. Evidencia en `docs/evidencia/dunst-sway-notificacion-2026-09-07.png`.
|
||
|
||
Y la cadena se cerró hasta el final, que es donde vive la promesa: con `--via-atuq` el emisor no es
|
||
`notify-send` sino **una página web dentro del navegador**. Ahí se ejercitan las cinco piezas —
|
||
`new Notification(...)` → `libxul` haciendo `dlopen("libnotify.so.4")` (que no es NEEDED de ningún
|
||
ELF, o sea invisible para cualquier auditor) → D-Bus → activación → dunst dibujando—, y el veredicto
|
||
es el `onshow` del motor, no el diff de píxeles: con el `.service` puesto llega a `MOSTRADA #33`, sin
|
||
él el motor dice `ERROR al mostrar` y no se dibuja nada. Evidencia en
|
||
`docs/evidencia/atuq-notificacion-web-sway-2026-09-07.png`.
|
||
|
||
Vale la pena decir qué prueba eso de más: que en el proceso de `atuq` **la GLib no se duplica**. El
|
||
navegador usa la cadena `-shared` (la que arrastra su GTK3) y `libnotify.so.4` enlaza esa misma, así
|
||
que el `dlopen` no repite GObject. El que sí lo repetía era `dunstify`, y por eso no se shipea.
|
||
|
||
⚠ De paso apareció otra media función, y ésta toca a `libnotify` directamente: **`dunstify`
|
||
segfaultea hasta en `--help`** porque mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que
|
||
arrastra `libnotify.so.4` — dos copias de GObject en un proceso. No se shipea; el emisor es
|
||
`notify-send`, que viene dentro del artefacto de libnotify y enlaza toda la cadena compartida. Vale
|
||
como recordatorio de que en este corpus **quién enlaza qué GLib es parte del contrato**, no un
|
||
detalle del build.
|
||
|
||
**Lo que esta sección cambia de fondo:** hasta hoy la pregunta era «¿arranca?», y la respuesta era
|
||
sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
|
||
porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
|
||
|
||
#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
|
||
|
||
Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
|
||
|
||
| corrida | qué pasa |
|
||
|---|---|
|
||
| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
|
||
| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
|
||
| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
|
||
| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
|
||
|
||
Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
|
||
y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
|
||
descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
|
||
|
||
Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
|
||
navegador» (perseguir un bug que no existe) o «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 dice en su cabecera POR QUÉ — 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.
|
||
|
||
⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
|
||
falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
|
||
|
||
### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
|
||
|
||
El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
|
||
—**¿se ve el vídeo?**— no la contesta ningún auditor de ELF, y al hacerla aparecieron **dos errores
|
||
del mapa y un bug real del navegador**.
|
||
|
||
**Error 1: «no hay receta de ffmpeg».** La hay. `recipes/ffmpeg.toml`, 7.1, sellada, publica
|
||
`libavcodec.so.61` —uno de los once sonames que `libxul.so` sondea por `dlopen` literal— y **ya
|
||
viajaba en la clausura de los cuatro escritorios**, arrastrada por `mpv`. Comprobado con
|
||
`yupana.membresia`, que es la vista honesta por par `(cola, nombre)`, no leyendo el TOML:
|
||
|
||
escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
|
||
ffmpeg ✓ en los cuatro · libva ✓ en los cuatro
|
||
|
||
Lo que faltaba no era la receta: era **la lista de raíces de `scripts/atuq-nested.sh`**, escrita a
|
||
mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto —
|
||
la misma forma que `gcc-libs` el día anterior. Y la comprobación previa que sí se pudo hacer sin
|
||
correr nada: de los 82 símbolos con forma `av*_` que `libxul` dlsimboliza, **los 9 que nuestro
|
||
`libavcodec` no exporta son los de la API vieja** (`avcodec_decode_video2`, `av_lockmgr_register`,
|
||
`avcodec_register_all`…), que Firefox sólo pide para las versiones 53-57.
|
||
|
||
**Error 2: «VA-API, sin receta».** También la hay, y también está en las cuatro imágenes. El hueco
|
||
es real pero por la OTRA mitad: las tres recetas de mesa construyen con `-Dgallium-va=disabled` y
|
||
`-Dvideo-codecs=` vacío ⇒ **no existe un solo `*_drv_video.so`**. Un cargador sin ICD no acelera
|
||
nada, exactamente como `vulkan-loader`. ⚠ Y con una trampa nueva: ahora que `libva` está en el
|
||
rootfs, **el mapa de `--dlopen` ya no la reporta**, porque mide presencia de fichero. La librería
|
||
presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no
|
||
puede decirlo.
|
||
|
||
**El bug real: AV1 no reproducía.** Gecko **se come el primer elemento de `LD_LIBRARY_PATH`** al
|
||
lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía `/usr/lib/atuq` una sola vez —o
|
||
sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d)
|
||
y anotaba `FFVPX: Link result: NoProvidedLib`. Y ahí **no falla**: se cae al ffmpeg del sistema, que
|
||
cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero **no AV1 por software**, porque el decodificador `av1`
|
||
de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en
|
||
`readyState=1` para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.
|
||
|
||
Tres corridas del MISMO artefacto que sólo cambian esa variable:
|
||
|
||
LD=/usr/lib/atuq:/usr/lib:/lib → RDD NoProvidedLib AV1 ✗
|
||
LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓
|
||
LD=/relleno:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓ (el relleno se lo come)
|
||
|
||
El arreglo es repetir el appdir en `recipes/atuq/bin/atuq`. Feo y correcto mientras no haya
|
||
`patchelf` en el corpus para grabar `RUNPATH=$ORIGIN`, que borraría la variable entera.
|
||
|
||
**Lo que quedó medido**, con `scripts/test-atuq-codecs.sh`, headless, por el lanzador de verdad y
|
||
sin `LD_LIBRARY_PATH` de afuera:
|
||
|
||
| Muestra | Veredicto |
|
||
|---|---|
|
||
| H.264 + AAC (mp4) | ✅ `t=3.00`, 75 cuadros |
|
||
| VP9 + Opus (webm) | ✅ `t=3.00` |
|
||
| AV1 (mp4) | ✅ `t=3.00` — sólo desde el arreglo del lanzador |
|
||
| MP3 | ✅ |
|
||
| FLAC | ✅ |
|
||
|
||
El veredicto **no es «se creó el decodificador»**: es que `currentTime` AVANCE. La distinción no es
|
||
retórica — en el caso de AV1 el decodificador se creaba (`RemoteDecoderChild … codec: av1`) y no
|
||
entregaba un solo cuadro. El guardián corre con `--negative-control`, que saltea el lanzador y
|
||
**exige que AV1 falle**: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca
|
||
falló se ven igual desde afuera.
|
||
|
||
**Dos bugs de andamiaje que salieron de paso**, los dos en `atuq-nested.sh` y los dos escondidos
|
||
detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + `set -e`) y mataba
|
||
el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\n` que `$(cat …)`
|
||
recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El
|
||
primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces.
|
||
|
||
### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09)
|
||
|
||
El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el
|
||
re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas,
|
||
y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente:
|
||
|
||
VIVO el JS de atuq.cfg corre
|
||
VENTANA con gBrowser
|
||
PREF splitView.enabled=true
|
||
WRAPPER tabs=2
|
||
ACTIVA si
|
||
BROWSERS 2
|
||
|
||
**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí
|
||
sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa
|
||
`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La
|
||
sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`.
|
||
|
||
Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el
|
||
orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al
|
||
revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de
|
||
arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después
|
||
extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra.
|
||
|
||
**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En
|
||
`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con
|
||
`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y
|
||
`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se
|
||
queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que
|
||
contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.**
|
||
|
||
Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los
|
||
contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin
|
||
usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés:
|
||
declarar como deuda propia algo que upstream ya nos dio.
|
||
|
||
⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con
|
||
la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que
|
||
lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador
|
||
vacío para «tenerlo listo».
|
||
|
||
El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que
|
||
si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija
|
||
y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a
|
||
una que funciona.
|
||
|
||
### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo
|
||
|
||
Escribir la receta destapó un muro que este documento no había visto: `[source]` era
|
||
**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de
|
||
nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se
|
||
ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al
|
||
worker a tener acceso a un repo privado.
|
||
|
||
Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por
|
||
**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en
|
||
Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por
|
||
bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no.
|
||
|
||
Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan
|
||
diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.
|
||
|
||
**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`.
|
||
La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/
|
||
CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de
|
||
`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva
|
||
orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo
|
||
orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de
|
||
la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de
|
||
chrome arranca con la interfaz vieja y *parece* que el overlay no agarró.
|
||
|
||
La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`,
|
||
`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css`
|
||
vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el
|
||
primer arranque. Falta construirlo: su dep `firefox` está en vuelo.
|
||
|
||
### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver
|
||
|
||
El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión
|
||
(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»:
|
||
|
||
1. **EXDEV al hidratar.** `hydrate` proyecta con hardlinks y `linkat()` rechaza cruzar un punto de
|
||
montaje aunque los dos lados sean el mismo filesystem. Acá el store es `/dev/sdb` bind-monteado
|
||
y `work/` vive en `/dev/sdc`. El rootfs va bajo `/mnt/cosecha`, donde el volumen está montado
|
||
entero. Se comprueba con `findmnt -T`, **nunca** con `stat -c %d`.
|
||
2. **El lanzador no puede ser un symlink.** Ni el binario ni sus `.so` traen `RPATH`/`RUNPATH`
|
||
(`readelf -d`), así que el motor no encontraba su propio `libnspr4.so` y moría en
|
||
`XPCOMGlueLoad`. Es un script con `LD_LIBRARY_PATH` + `exec`, como Debian y Fedora. La
|
||
alternativa limpia —`RPATH=$ORIGIN` con `patchelf`, que es lo que hace Alpine— espera a que
|
||
`patchelf` exista como receta del corpus.
|
||
3. **Un bug del corpus, no de atuq: `atk` se había tragado GObject.** Declaraba la variante
|
||
ESTÁTICA de glib y produce un objeto compartido, así que `libatk-1.0.so` llevaba una copia
|
||
entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 `GLib-GObject-CRITICAL` y
|
||
`Segmentation fault`. **El síntoma no nombra a atk**, y no se ve mirando el rootfs: había una
|
||
sola `libgobject`. Se cazó preguntando quién DEFINE el símbolo:
|
||
`nm -D --defined-only <cada .so> | grep " T g_type_register_static"` — deben salir uno, salieron
|
||
dos.
|
||
|
||
Radio medido antes de tocar (`yupana radio atk`): 4 transitivos, 3 sellados a deuda; GNOME fuera
|
||
porque usa gtk4. La cadena `atk → gtk3 → firefox → atuq` volvió a sellar 4/4, y **firefox tardó
|
||
~50 minutos, no las cuatro horas** que repetía este documento — el número venía del folclore de la
|
||
receta, no de una medición.
|
||
|
||
**La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una
|
||
pantalla.** La clausura decía 100% con un navegador que no llegaba a pintar un píxel.
|
||
|
||
### 2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo
|
||
|
||
La página de inicio y la pestaña nueva parecían un `pref` y resultaron ser un frente. Lo que se
|
||
midió, en orden:
|
||
|
||
| Mecanismo | Resultado |
|
||
|---|---|
|
||
| `distribution/extensions/` — el clásico de las distros | **No instala nada.** Firefox retiró el *sideloading*. El `extensions.json` del perfil ni lo mencionaba y el log no dijo una palabra: fallo perfectamente silencioso |
|
||
| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` |
|
||
| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA |
|
||
|
||
**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en
|
||
`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta**
|
||
es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como
|
||
pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y
|
||
por lo tanto la pref de firma se estaba ignorando.
|
||
|
||
De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su
|
||
vuelta:
|
||
|
||
1. El default sale del **milestone** (`milestone.is_release_or_beta`), no del canal de actualización.
|
||
El nuestro es `default` y la exigencia estaba activa igual.
|
||
2. **Se desactiva con el valor VACÍO, no con cero.** `=0` muere con «takes 0 values»: es booleana y
|
||
el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido.
|
||
|
||
**El precio, escrito:** este Firefox deja de exigir la firma de Mozilla para cualquier complemento.
|
||
La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia
|
||
externa que esta distro existe para no tener. Y no es un desvío: **sin esto, el `sct` del §6.1
|
||
tampoco se podría shipear nunca**, así que la unidad 6 dependía de ésta sin que el plan lo dijera.
|
||
|
||
**Método que conviene repetir:** la prueba del testigo se hizo **sin reconstruir**, montando el
|
||
`.cfg` modificado con `--ro-bind` por encima del artefacto. Editar el fichero en el rootfs habría
|
||
escrito sobre un **hardlink del store** y corrompido el artefacto sellado.
|
||
|
||
### 2.sexies Lo que quedó PROBADO, y con qué
|
||
|
||
Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron:
|
||
|
||
| Afirmación | Prueba |
|
||
|---|---|
|
||
| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** |
|
||
| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil |
|
||
| El branding se ve | captura de la pantalla real con `grim` desde dentro de la jaula, y la página de inicio renderizada por el propio navegador con `--headless --screenshot` |
|
||
| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** |
|
||
|
||
Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de
|
||
`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes
|
||
distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve
|
||
por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el
|
||
mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**:
|
||
apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce.
|
||
|
||
**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos
|
||
son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50
|
||
minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que
|
||
el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga.
|
||
|
||
**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está
|
||
en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí.
|
||
|
||
## 7. La costura: un host de native messaging en Rust
|
||
|
||
Todo lo del §6 que no es CSS pasa por **un solo mecanismo**: un proceso Rust que habla native
|
||
messaging con la extensión de `atuq` y, del otro lado, con la suite (`agora` para identidad Ed25519,
|
||
`khipu`, el store, `foreign-*`, `cortafuegos`).
|
||
|
||
Que sea **uno** y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un
|
||
proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); `atuq` sólo lo declara como
|
||
dep y le deja el manifiesto de native messaging en su sitio. **El nombre del crate se decide en
|
||
tawasuyu y con su regla 10 puesta** — acá no se bautiza nada de allá.
|
||
|
||
### 7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)
|
||
|
||
Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO
|
||
build —propio, rebrandeado, con `MOZ_REQUIRE_SIGNING` vacío—, así que se puso una **sonda**:
|
||
extensión de diez líneas con permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`.
|
||
Vive en `scripts/test-atuq-nativo.py`, con control negativo:
|
||
|
||
positivo MENSAJE {"ok":true}
|
||
control DESCONECTADO No such native application puente_atuq
|
||
|
||
El control no sólo falla: **nombra la causa**, que es lo que confirma que la ruta del manifiesto es
|
||
la que se probó. Lo que hay que saber para escribir el host:
|
||
|
||
| Qué | Medido |
|
||
|---|---|
|
||
| Dónde va el manifiesto | **`/usr/lib/mozilla/native-messaging-hosts/`**, NO el appdir. Sale de `XRESysNativeManifests`, un `/usr/lib/mozilla` compilado; el rebranding a `atuq` no lo mueve |
|
||
| Cómo se instala la extensión | por el **escaneo de `distribution/extensions/`**. `ExtensionSettings.install_url` con `file://` no instala nada, ni con `force_installed` ni con el XPI dentro del appdir, y **sin una línea de log** |
|
||
| Forma del host | un proceso **largo**: si escribe la respuesta y sale, Gecko cierra el puerto sin entregarla — `onDisconnect` «sin error y sin mensaje», el peor informe posible |
|
||
| Con qué diagnosticar | el error del puerto de `connectNative`. `sendNativeMessage` devuelve «An unexpected error occurred» para todo y no distingue nada |
|
||
|
||
⚠ La tercera fila del cuadro corrige una lectura fácil de `policies.json`: los dos mecanismos
|
||
apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala.
|
||
Fija y configura; instalar, no.
|
||
|
||
### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)
|
||
|
||
La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y
|
||
mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde
|
||
`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos
|
||
nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy
|
||
`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque
|
||
los dos apuntan a los mismos ficheros.
|
||
|
||
`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre
|
||
`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta
|
||
misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—,
|
||
tres escenarios:
|
||
|
||
| escenario | qué se rompe | motor (`homepageOverride`) | perfil |
|
||
|---|---|---|---|
|
||
| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas |
|
||
| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** |
|
||
| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** |
|
||
|
||
⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis
|
||
queda confirmado y el README corregido.
|
||
|
||
Dos cosas que este guión trae y que valen más que el veredicto:
|
||
|
||
1. **El control es por construcción, no un modo aparte.** La sonda que le pregunta al motor vive en
|
||
`distribution/extensions/` y **ninguna política la nombra**: si la sonda habla, el escaneo está
|
||
vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda
|
||
el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala.
|
||
2. **La segunda señal que probé NO discrimina, y queda dicho.** Esperaba que el `location` de
|
||
`extensions.json` separara los mecanismos (`app-system-defaults` vs. `app-profile`). No: los dos
|
||
XPI salen `app-profile` **también** cuando los instala la carpeta y no hay política que los
|
||
mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal
|
||
que uno cree que discrimina y no discrimina es peor que ninguna.
|
||
|
||
**Por qué esto es un guardián y no un test más.** Mozilla lleva años retirando el *sideloading*, y
|
||
el día que la carpeta deje de instalar, atuq arranca **sin ninguna de sus extensiones** y sin una
|
||
línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor
|
||
simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la
|
||
dep, no cuando alguien toca atuq.
|
||
|
||
⚠ **Y por eso mismo hay que decir lo que todavía NO está: ninguno de los `test-atuq-*` corre en el
|
||
latido.** `cosecha-cron.sh` sólo ejecuta `vigia-sonames.py`; los de atuq se corren a mano. Meterlos
|
||
cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga
|
||
hidratado el rootfs de `scripts/atuq-nested.sh`, así que **es una decisión con precio y no un
|
||
`echo` más en el cron** — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el
|
||
sitio es el bloque de guardianes de `cosecha-cron.sh` (L341-352).
|
||
|
||
### 7.quater El host, escrito — y por qué la extensión NO puede hablarle al testigo (2026-09-10)
|
||
|
||
El §6.1 decía que `sct` v1 es «extensión con `webRequest` bloqueante que hashea cada respuesta de
|
||
script y **consulta al testigo** antes de dejarla pasar». La primera mitad es correcta; la segunda es
|
||
imposible tal como está escrita, y conviene decirlo porque parecía un atajo para saltarse esta
|
||
unidad: **el cable de `puriy-sct-testigo` es un POST con `postcard` en el body**, no JSON. Un JS no
|
||
puede ser su cliente. Y reimplementar el registro TOFU en la extensión sería fabricar un sustituto
|
||
paralelo del original —lo que la regla 10 de tawasuyu prohíbe— además de duplicar la única pieza que
|
||
ya estaba certificada. Entonces v1 pasa por el host, y el host es esta unidad.
|
||
|
||
Se decidió con el operador, con el `grep` de la regla 10 hecho primero: **ningún término del dominio
|
||
significaba «host de native messaging»**, y los que suenan a mensajero están todos ocupados por
|
||
dominios ajenos y grandes (`chasqui` es un type broker, `chaka` el puente a COBOL, `paloma` el cliente
|
||
de correo, `tampu` el común de objetos). El nombre salió del título de este propio §7: **la costura**.
|
||
|
||
| Pieza | Dónde | Por qué ahí |
|
||
|---|---|---|
|
||
| `puriy-costura` | `00_unanchay/puriy/` | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: `puriy-sct-testigo`, `rimay/verbo-daemon`, `watuy-daemon` |
|
||
| `foreign-webext` | `shared/` | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva `[[bin]]`) |
|
||
| `recipes/puriy-costura.toml` | takana | el artefacto: estático musl, pineado por commit, `--locked` |
|
||
|
||
Verbos v1: `ping`, `sct.observe`, `sct.state`. Claves del cable en inglés (regla 7 de allá y regla 4
|
||
de acá: se tipean). `phase` es `learning`/`stable`/`not-evaluable`, y un `events[]` vacío es lo normal
|
||
— sólo hay evento cuando un origen **ya estable** ejecuta un hash que nadie vio nunca.
|
||
|
||
**Los tres tests que hacen que el veredicto signifique algo** (de catorce): el mismo cambio de script
|
||
mientras el origen todavía aprende **no** alerta; rotar `?v=` con el mismo código **tampoco** (la
|
||
identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de
|
||
supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje **sobrevive al
|
||
cierre del navegador**, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca
|
||
un evento.
|
||
|
||
**Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:**
|
||
|
||
1. **Qué bytes se hashean.** La extensión manda el TEXTO ya decodificado; se hashea su UTF-8. La
|
||
detección vale —el TOFU compara lo mismo contra lo mismo—, pero nuestro hash **no es comparable**
|
||
con el de los bytes servidos que publique un tercero, ni con el de la v2 (el gancho en el script
|
||
loader). Un hash que uno cree comparable y no lo es sería peor que no tenerlo.
|
||
2. **La semilla del log es provisional**: se deriva del directorio de estado, así que es
|
||
determinística (el log se reanuda) pero **no es un secreto** ⇒ la bitácora es a prueba de
|
||
REESCRITURA, no de SUPLANTACIÓN. La de verdad sale del perfil cuando el almacén del perfil exista.
|
||
|
||
**Y lo que queda medido para la unidad 6, preguntándole al artefacto y no a la documentación de
|
||
Mozilla** (§2.sexies): `filterResponseData` y el permiso `webRequestFilterResponse` **están** en
|
||
nuestro build (`omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`), así que la
|
||
extensión va a poder leer el cuerpo de un script. Lo que NO va a poder es ver los scripts **inline**
|
||
por esa vía —`filterResponseData` entrega el documento, no cada `<script>`—, y eso está bien para v1:
|
||
el ataque que `sct` nombra es la sustitución en el CDN, que es exactamente el caso `<script src>`.
|
||
|
||
## 8. Plan, por unidades de trabajo
|
||
|
||
Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
|
||
siguiente.
|
||
|
||
| # | Unidad | Puerta que abre | Bloqueada por |
|
||
|---|---|---|---|
|
||
| 1 | **`waterfox` SELLA** ✅ 2026-09-06 — `b3:88b5a762`, 377 M, BuildID determinista **y REPRODUCE bit a bit**. Cuatro muros, **ninguno de la receta**: takana viejo en el worker, el watchdog barriendo el post-mortem, deps estáticas vs. el cairo bundleado, y una rotura de upstream sólo-Linux | valida la tesis del reúso | — |
|
||
| 2 | ~~`llvm-toolchain`~~ → **`apk add lld` + `compiler="clang"`** ✅ 2026-09-05 | LTO **y** el muro 3 | — |
|
||
| 3 | Construir el `firefox` con LTO (hash `b3:6f2a3b2f`) | el eje de velocidad **y** las extensiones de `atuq` | 2 ✅ |
|
||
| 3.a | **PGO ENCENDIDO** ✅ 2026-09-07 — perfil de 501.521 funciones recogido bajo headless, publicado en el mirror y pineado por sha256; 189 compilaciones con `-fprofile-use`. Sin `jarlog` todavía | la otra mitad de la ganancia | 3 ✅ |
|
||
| 3.b | **RLBox ENCENDIDO** ✅ 2026-09-06 — cadena wasm de CINCO recetas selladas + guardián que la exige en el binario; firefox sellado **y REPRODUCE bit a bit** (`verificar-repro.sh`: 0 no-determinismos) | cierra el hueco de seguridad del §3.bis | 2 ✅ |
|
||
| 4 | **`atuq`: receta derivada + overlay de chrome** ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
|
||
| 4.b | Branding + re-empaque de `omni.ja` ✅ v0.2 | el chrome de verdad | — |
|
||
| 4.d | **Abrirlo en pantalla** — destapó EXDEV, el lanzador, el `atk` envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
|
||
| 4.e | **v0.3: página de inicio y pestaña nueva** por extensión de sistema; exigió `MOZ_REQUIRE_SIGNING` vacío. **Verificado 2026-09-07** con `scripts/test-atuq-inicio.py`: se le pregunta al MOTOR (`browserSettings.homepageOverride` / `newTabPageOverride`) y da `moz-extension://…/inicio.html` en las dos, contra `about:home`/`about:newtab` en el control | y con eso queda destrabada la unidad 6 (`sct`) | rebuild de firefox |
|
||
| 4.f | **Los códecs, medidos** ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el `LD_LIBRARY_PATH` que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido `scripts/test-atuq-codecs.sh` con control negativo | «se ve el vídeo», que ningún auditor de ELF podía responder | — |
|
||
| 4.c | `MOZ_BUILD_DATE` determinista ✅ — `BuildID=19700101000001`, con guardián en install y **`why-differs` 56/56 sobre dos builds reales** | el invariante de reproducibilidad | — |
|
||
| 4.g | **El chrome se programa desde `atuq.cfg`, sin abrir `omni.ja`** ✅ 2026-09-09 — y de paso: la **vista dividida ya la trae el motor y viene prendida** (fx 154), ejercitada de verdad con `gBrowser.addTabSplitView` ⇒ `activeSplitView` + 2 navegadores. Guardián `scripts/test-atuq-chrome.py` con control negativo del propio motor | retira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el `jarlog` | — |
|
||
| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
|
||
| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
|
||
| 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
|
||
| 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ |
|
||
| 8 | **Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10.** `archive.add`/`archive.search` + la extensión `archivo@atuq.tawasuyu`: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián `scripts/test-atuq-archivo.py`, cuyo control negativo además **dice quién** impidió archivar lo privado. **Falta la mitad semántica**: un motor `rag-motor::RagMotor` (como `willay-rag`), que exige daemon de embeddings + LLM real | 6.3 | 5 ✅, 7 ✅ |
|
||
| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ |
|
||
| 10 | **Foco (6.5) — 2026-09-10.** Entra al corpus la cadena `nftables` (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión `foco` MUESTRA el estado y **no tiene verbo para apagarlo**. **Falta** quién pone a `atuq` en un cgroup (delegación, decisión de arje) y quién aplica la política (root) | 6.5 | 6 ✅ |
|
||
| 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — |
|
||
|
||
Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
|
||
toolchain. Si hay dos frentes, van juntas.
|
||
|
||
## 9. Por qué esto no abandona a `puriy`
|
||
|
||
`puriy` es el motor DOM/CSS propio con JS real (QuickJS-NG sobre `wasmi`) cuyo límite declarado no es
|
||
el lenguaje sino **el wiring nativo**: DOM bindings completos, red, render. Eso no se acelera
|
||
escribiendo otro navegador; se acelera con tiempo.
|
||
|
||
`atuq` no le compite porque no comparte una sola línea con él, y **le sirve** porque todo lo del §6
|
||
vive del lado Rust: el host del §7, el testigo de `sct`, las descargas al CAS, el archivo con RAG.
|
||
Son **agnósticos del motor**. Cuando `puriy` madure, se enchufan del mismo lado sin reescribirse.
|
||
|
||
Dicho al revés, que es como conviene recordarlo: **`atuq` es el navegador que se puede usar mientras
|
||
`puriy` crece, y el andamio de las features que `puriy` va a heredar.** Si dentro de dos años `atuq`
|
||
se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.
|