> 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…
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
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())