tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1181Shell 82Rust 62Markdown 521515 Definiciones

atuq §6.8: probado que el paquete sale por el proxy del contenedor — y con control negativo

Era lo único que la v0.5 dejó sin probar, y las tres puertas que había
encontrado siguen cerradas: no hay flag de línea de comandos para el
userContextId, Marionette no toma `-remote-allow-system-access` en la jaula, y
`tabs.create({cookieStoreId})` exige el permiso `cookies` — que no se le da a la
extensión del proxy sólo para que pueda probarse a sí misma.

La cuarta puerta estaba abierta: el fichero de SESIÓN guarda el userContextId de
cada pestaña y Gecko lo restaura. Se fabrica a mano — `mozLz40\0` + tamaño + un
bloque LZ4, y un bloque de sólo literales es LZ4 válido: veinte líneas, sin
librería.

Y la medición no le pregunta nada al navegador: le pone dos oídos en la red y
mira a cuál llama. Dos pestañas piden la MISMA url, una sin contenedor y otra en
«Banco»:

    al destino (8099)  GET /directo   <- la de sin contenedor, directa
    al proxy   (9099)  \x05\x01\x00   <- saludo SOCKS5 de la de «Banco»

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 proxy.onRequest mirando el cookieStoreId. La
pestaña sin contenedor no es decorativa: sin ella, «todo fue por el proxy» y «el
ruteo anda» se verían iguales.

CONTROL NEGATIVO, que es lo que este repo se exige desde hoy: con
`--negative-control` no se configura el proxy y se exige lo contrario — las dos
pestañas directas y nadie llamando al proxy. Corrido: pasa. 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.

Tres detalles sin los cuales la prueba mide un silencio y se lee como fallo:
restore_on_demand=false, allow_hijacking_localhost=true, y el
triggeringPrincipal_base64 en cada entrada de sesión.

El artefacto se resuelve por `hammer hash` y no por glob, que es la lección de
hammer-03 de esta madrugada: con dos artefactos de la misma receta en el store,
`ls | head -1` es una ruleta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ

2e093f9cab90ecc7be38a583a4d3ce4d14d7e4ed · por Sergio · (hace 1 mes) · padre 7f912c1e2 · ver el árbol en este commit

Delta semántico · 8

scripts/test-atuq-ruteo.py

3 archivos · +326 −171

diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index a234fd7b..84b2f2fb 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -393,6 +393,7 @@ Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`:
 | 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 |
 
@@ -404,172 +405,49 @@ creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` cop
 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.
 
-**Lo que NO está probado, dicho por su nombre:** que una petición hecha en el contenedor «Banco»
-salga por el proxy de «Banco». Se intentó y **se documenta el intento**, porque el que venga después
-no tiene por qué volver a descubrir el muro.
-
-El diseño de la prueba está bien y no necesita red ni servidor: se pide la MISMA url —un puerto
-donde no hay nadie— desde dos contenedores y se comparan los dos errores. Sin proxy tiene que dar
-`about:neterror?e=connectionFailure`; con el proxy de «Banco» apuntando a un puerto cerrado tiene
-que dar `e=proxyConnectFailure`. **Que los códigos sean DISTINTOS es la prueba**: el segundo sólo
-aparece si Gecko intentó hablar con un proxy, y el único que se lo pudo indicar es nuestro
-`proxy.onRequest` mirando el `cookieStoreId`. El control sin proxy no es decorativo — sin él, dos
-fallos podrían ser el mismo fallo.
-
-Lo que falta es **abrir una pestaña EN un contenedor**, y las tres puertas están cerradas:
-
-1. **Desde la línea de comandos, no existe.** No hay flag para el `userContextId` de la pestaña
-   inicial.
-2. **Marionette SÍ está en el artefacto** (`chrome/remote/content/marionette/` en `omni.ja`) y
-   responde: se le habló con un cliente propio de 60 líneas —el protocolo es `<longitud>:<json>`—,
-   abrió sesión y aceptó comandos. Pero abrir una pestaña de contenedor pide **contexto chrome**, y
-   eso exige `-remote-allow-system-access`; en esta jaula NO lo tomó ni por bandera, ni combinado
-   con `--remote-debugging-port`, ni por la variable `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` que el
-   propio `RemoteAgent.sys.mjs` lee en su constructor (comprobado que la variable llega al proceso).
-   Queda como cabo suelto medido, no como suposición.
-3. **Desde la extensión tampoco, y por una razón de diseño que conviene NO saltarse:**
-   `tabs.create({cookieStoreId})` exige el permiso `cookies` — está en
-   `ext-tabs-base.js:getUserContextIdForCookieStoreId`, que lanza `No permission for cookieStoreId`
-   sin él. Agregarle a la extensión del proxy un permiso sobre TODAS las cookies del usuario para
-   poder probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del
-   test. No se hace.
-
-Así que el camino que queda es el (2) con el `allowSystemAccess` resuelto, o una prueba desde el
-lado del sistema (mirar el `connect()` con `strace` sobre el proceso). Ninguno de los dos entra en
-esta unidad.
-
-**Dos obstáculos del método, que valen para la próxima:**
-
-- **La consola de una extensión es CONTENIDO, no chrome.** `devtools.console.stdout.chrome` viene
-  en `true` de fábrica y NO la incluye; hace falta `devtools.console.stdout.content`. Sin esa pref
-  los mensajes de la extensión sólo viven en la consola del navegador, que en headless no mira
-  nadie.
-- **Un `moz-extension://` NO se puede abrir desde la línea de comandos.** El manejador intenta
-  resolverlo como fichero y muere con `NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su
-  lugar — o sea que la captura sale de otra página y parece que la tuya no funciona. Y `--screenshot`
-  dispara al `load`, que puede ocurrir ANTES de que termine el arranque de las extensiones: ahí el
-  global `browser` todavía no está inyectado. Eso destapó un fallo real y se arregló: la página de
-  opciones distinguía mal «no hay contenedores» de «la API no está» y mostraba un mensaje FALSO.
-
-### 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í.
+**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.
 
 ## 7. La costura: un host de native messaging en Rust
 
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index c7f1f3f0..d1d214e3 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -101,8 +101,6 @@ y eso lo decide quien dibujó el zorro.
   de **preservar el orden del `jarlog`** cuando el PGO exista, o se tira a la basura la
   optimización de arranque.
 - **La extensión de `sct`** y el host de native messaging (SDD 26 §6.1 y §7).
-- **Del §6.8: la prueba del paquete.** Está probado que los contenedores se crean, que la extensión
-  se instala y que su mapa se construye contra ellos; NO está probado con una captura que una
-  petición hecha en el contenedor «Banco» salga por el proxy de «Banco». Eso pide automatizar la UI
-  (abrir una pestaña de contenedor no se puede desde la línea de comandos), y se anota como
-  pendiente en vez de darlo por hecho.
+*(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco»
+está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a
+qué puerto llama el navegador. Trae control negativo.)*
diff --git a/scripts/test-atuq-ruteo.py b/scripts/test-atuq-ruteo.py
new file mode 100755
index 00000000..84b184ef
--- /dev/null
+++ b/scripts/test-atuq-ruteo.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+"""¿Una petición hecha DENTRO de un contenedor sale por el proxy de ESE contenedor?
+
+    python3 scripts/test-atuq-ruteo.py                     # sale 0 si el ruteo funciona
+    python3 scripts/test-atuq-ruteo.py --negative-control  # SABOTEA la config y exige que se note
+
+Es la afirmación central del SDD 26 §6.8, y la que la v0.5 dejó sin probar. Esta prueba **no le
+pregunta nada al navegador**: le pone dos oídos en la red y mira a cuál de los dos llama.
+
+    127.0.0.1:8099   el destino de verdad; contesta un HTTP 200
+    127.0.0.1:9099   el «proxy» de «Banco»; no habla SOCKS, sólo anota quién lo saludó
+
+Dos pestañas piden la MISMA url. Si el ruteo funciona:
+
+  · la pestaña SIN contenedor  ⇒ llega un `GET /directo` al 8099
+  · la pestaña en «Banco»      ⇒ llega un saludo SOCKS5 (`\\x05…`) al 9099, y NADA al 8099
+
+EL CONTROL NEGATIVO NO ES UN EXTRA. Con `--negative-control` no se le pone proxy a «Banco» y se
+exige el resultado CONTRARIO: que las dos pestañas salgan directas. Una prueba que nunca falló no se
+sabe si sirve — sin este modo, una prueba rota que siempre dice «ok» se ve idéntica a una que
+funciona. Los dos modos tienen que correr: el positivo caza que el ruteo se apague, el negativo caza
+que la prueba se haya vuelto ciega.
+
+**El saludo SOCKS5 en el 9099 es la prueba.** Sólo puede aparecer si Gecko decidió hablar con un
+proxy para esa petición, y el único que se lo pudo indicar es el `proxy.onRequest` de
+`extensions/proxy/` mirando el `cookieStoreId`. La pestaña sin contenedor **no es decorativa**: es
+el control. Sin ella, «todo fue por el proxy» y «el ruteo anda» se verían iguales, igual que dos
+silencios se verían iguales que dos aciertos.
+
+POR QUÉ LAS PESTAÑAS SE ABREN POR SESSIONSTORE, que es la parte no obvia. Abrir una pestaña EN un
+contenedor no se puede desde la línea de comandos (no hay flag), ni desde Marionette sin contexto
+chrome (pide `-remote-allow-system-access`), ni desde la propia extensión sin el permiso `cookies`
+—`ext-tabs-base.js:getUserContextIdForCookieStoreId` lo exige—, y ese permiso no se le da a la
+extensión del proxy sólo para poder probarla: sería pagar con la superficie de ataque del producto
+una comodidad del test. Pero el fichero de sesión SÍ 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;
+un bloque de SÓLO LITERALES es LZ4 válido y se escribe en veinte líneas sin depender de nada.
+
+REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado.
+"""
+import json
+import os
+import shutil
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
+ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
+HAMMER = os.path.join(ROOT, "target/release/hammer")
+STORE = os.path.join(ROOT, "store")
+PUERTO_DESTINO, PUERTO_PROXY = 8099, 9099
+URL = "http://127.0.0.1:%d" % PUERTO_DESTINO
+
+visto = {"destino": [], "proxy": []}
+
+
+def fatal(msg):
+    print(f"✗ {msg}", file=sys.stderr)
+    raise SystemExit(1)
+
+
+# ── El artefacto se resuelve por `hammer hash`, NUNCA por glob ────────────────────────────────
+# Con dos artefactos de la misma receta conviviendo en el store, `ls | head -1` es una ruleta: se
+# puede estar probando el de ayer y leerlo como éxito. La receta dice cuál es el vigente.
+def artefacto(receta, nombre):
+    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
+                       cwd=ROOT, capture_output=True, text=True)
+    if r.returncode != 0:
+        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
+    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
+    d = os.path.join(STORE, f"{h}-{nombre}")
+    if not os.path.isdir(d):
+        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
+    return d
+
+
+def oido(puerto, etiqueta, responder, hasta):
+    s = socket.socket()
+    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    try:
+        s.bind(("127.0.0.1", puerto))
+    except OSError as e:
+        fatal(f"el puerto {puerto} está ocupado ({e}) — la medición no valdría nada")
+    s.listen(8)
+    s.settimeout(1.0)
+    while time.time() < hasta:
+        try:
+            c, _ = s.accept()
+        except socket.timeout:
+            continue
+        except OSError:
+            break
+        try:
+            c.settimeout(3)
+            visto[etiqueta].append(c.recv(512))
+            if responder:
+                c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
+        except OSError:
+            pass
+        finally:
+            c.close()
+    s.close()
+
+
+# ── mozlz4 a mano ─────────────────────────────────────────────────────────────────────────────
+def lz4_literales(data: bytes) -> bytes:
+    out = bytearray()
+    n = len(data)
+    if n < 15:
+        out.append(n << 4)
+    else:
+        out.append(0xF0)
+        r = n - 15
+        while r >= 255:
+            out.append(255)
+            r -= 255
+        out.append(r)
+    out += data
+    return bytes(out)
+
+
+def mozlz4(obj) -> bytes:
+    raw = json.dumps(obj, separators=(",", ":")).encode()
+    return b"mozLz40\0" + struct.pack("<I", len(raw)) + lz4_literales(raw)
+
+
+def pestana(url, titulo, ucid):
+    # `triggeringPrincipal_base64: vQ==` es el principal de SISTEMA serializado. SIN un principal
+    # Gecko restaura la pestaña pero NO navega, y la prueba mediría un silencio en vez de un fallo.
+    e = {"url": url, "title": titulo, "triggeringPrincipal_base64": "vQ=="}
+    t = {"entries": [e], "index": 1, "hidden": False, "lastAccessed": 1, "searchMode": None}
+    if ucid:
+        t["userContextId"] = ucid
+    return t
+
+
+def correr(tmp, atuq, segundos):
+    guion = f"""
+    mkdir -p /root /salida/perfil
+    [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
+    timeout {segundos} /usr/bin/atuq --profile /salida/perfil --no-remote --headless \
+        >> /salida/consola.log 2>&1
+    exit 0
+    """
+    cmd = [
+        "bwrap",
+        "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq,
+        "--overlay-src", os.path.join(tmp, "politica"), "--tmp-overlay", "/",
+        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
+        "--tmpfs", "/tmp", "--tmpfs", "/run",
+        "--bind", os.path.join(tmp, "salida"), "/salida",
+        "--uid", "0", "--gid", "0",
+        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
+        "--setenv", "LD_LIBRARY_PATH", "/usr/lib:/lib",
+        "--setenv", "XDG_RUNTIME_DIR", "/run", "--setenv", "HOME", "/root",
+        "--setenv", "XDG_DATA_DIRS", "/usr/share",
+        "--setenv", "MOZ_HEADLESS", "1",
+        # Dentro de bwrap ya no se puede crear otro user namespace (`uid_map: EPERM`) ⇒ el sandbox
+        # de contenido de Gecko no arranca y CADA proceso de pestaña muere con SIGSEGV en bucle.
+        # Acá se mide el ruteo, no el sandbox del motor.
+        "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
+        "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
+        "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
+        "sh", "-c", guion,
+    ]
+    subprocess.run(cmd, check=False)
+
+
+def main():
+    negativo = "--negative-control" in sys.argv[1:]
+    if negativo:
+        print("== MODO CONTROL NEGATIVO: «Banco» se deja SIN proxy y se exige que NO se rutee")
+    if not os.path.isdir(RFS):
+        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
+    atuq = artefacto("atuq", "atuq")
+    print(f"artefacto: {os.path.basename(atuq)}")
+
+    tmp = tempfile.mkdtemp(prefix="atuq-ruteo-")
+    perfil = os.path.join(tmp, "salida", "perfil")
+    os.makedirs(os.path.join(tmp, "salida"))
+    pol_dir = os.path.join(tmp, "politica", "usr", "lib", "atuq", "distribution")
+    os.makedirs(pol_dir)
+    try:
+        # La política de la prueba sale de la del ARTEFACTO, con una sola cosa cambiada. Escribirla
+        # de cero probaría una política que no es la que se distribuye.
+        pol = json.load(open(os.path.join(atuq, "usr/lib/atuq/distribution/policies.json")))
+        if not negativo:
+            pol["policies"]["3rdparty"]["Extensions"]["proxy@atuq.tawasuyu"]["proxies"]["Banco"] = {
+                "type": "socks", "host": "127.0.0.1", "port": PUERTO_PROXY, "proxyDNS": True}
+        json.dump(pol, open(os.path.join(pol_dir, "policies.json"), "w"), indent=2)
+
+        hasta = time.time() + 240
+        for p, et, resp in ((PUERTO_DESTINO, "destino", True), (PUERTO_PROXY, "proxy", False)):
+            threading.Thread(target=oido, args=(p, et, resp, hasta), daemon=True).start()
+        time.sleep(0.5)
+
+        print("== pasada 1: crear el perfil (la política crea los contenedores)")
+        correr(tmp, atuq, 40)
+        cont = os.path.join(perfil, "containers.json")
+        if not os.path.exists(cont):
+            fatal("no se creó containers.json — ¿la política no aplicó?")
+        ids = {i["name"]: i["userContextId"]
+               for i in json.load(open(cont))["identities"] if "name" in i}
+        if "Banco" not in ids:
+            fatal(f"no está el contenedor «Banco» (hay {sorted(ids)})")
+        print(f"   «Banco» es userContextId={ids['Banco']}")
+
+        print("== fabricar la sesión: dos pestañas, misma url, distinto contenedor")
+        sesion = {
+            "version": ["sessionrestore", 1],
+            "windows": [{"tabs": [pestana(f"{URL}/directo", "directo", None),
+                                  pestana(f"{URL}/banco", "banco", ids["Banco"])],
+                         "selected": 1, "_closedTabs": [], "busy": False}],
+            "selectedWindow": 1,
+            "session": {"lastUpdate": 1, "startTime": 1, "recentCrashes": 0},
+            "global": {},
+        }
+        bkp = os.path.join(perfil, "sessionstore-backups")
+        os.makedirs(bkp, exist_ok=True)
+        open(os.path.join(bkp, "recovery.jsonlz4"), "wb").write(mozlz4(sesion))
+        with open(os.path.join(perfil, "user.js"), "a") as fh:
+            fh.write('user_pref("browser.startup.page", 3);\n')
+            # Sin esto las pestañas restauradas quedan PEREZOSAS: no piden nada hasta que alguien
+            # las mira, y la prueba mediría dos silencios en vez de dos peticiones.
+            fh.write('user_pref("browser.sessionstore.restore_on_demand", false);\n')
+            # Gecko saltea el proxy para localhost salvo que se le diga lo contrario. Sin esto la
+            # petición de «Banco» iría directa y el fallo parecería de la extensión.
+            fh.write('user_pref("network.proxy.allow_hijacking_localhost", true);\n')
+
+        print("== pasada 2: restaurar la sesión y mirar quién llama a cada puerto")
+        visto["destino"].clear()
+        visto["proxy"].clear()
+        correr(tmp, atuq, 45)
+
+        print(f"   al DESTINO (8099) llegaron {len(visto['destino'])}: "
+              f"{[d[:24] for d in visto['destino']]}")
+        print(f"   al PROXY   (9099) llegaron {len(visto['proxy'])}: "
+              f"{[d[:24] for d in visto['proxy']]}")
+
+        directo = any(b"/directo" in d for d in visto["destino"])
+        banco_directo = any(b"/banco" in d for d in visto["destino"])
+        socks = any(d[:1] == b"\x05" for d in visto["proxy"])
+        if negativo:
+            casos = [
+                ("las DOS pestañas fueron directas al destino", directo and banco_directo),
+                ("y al proxy no llamó nadie", not socks),
+            ]
+        else:
+            casos = [
+                ("la pestaña SIN contenedor fue DIRECTA al destino", directo),
+                ("la de «Banco» NO apareció en el destino", not banco_directo),
+                ("al proxy llegó un saludo SOCKS5", socks),
+            ]
+        for nombre, ok in casos:
+            print(f"   [{'ok' if ok else 'NO'}] {nombre}")
+        bien = all(ok for _, ok in casos)
+        if negativo:
+            veredicto = ("sin proxy configurado NO se rutea, y la prueba lo distingue" if bien
+                         else "NO concluyente: la prueba no distingue el caso sin proxy")
+        else:
+            veredicto = ("el contenedor SÍ sale por su proxy" if bien
+                         else "NO concluyente — mirá consola.log")
+        print("RESULTADO:", veredicto)
+        if not bien:
+            shutil.copy(os.path.join(tmp, "salida", "consola.log"),
+                        os.path.join(ROOT, "work", "atuq-ruteo.log"))
+            print("   log en work/atuq-ruteo.log")
+        return 0 if bien else 1
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
+sys.exit(main())