> 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 v0.5: proxy por contenedor — el primer diferenciador del §6 que se paga entero
Es el único de la lista del SDD 26 §6 que no pasa por el host de native
messaging del §7: es API de Firefox y nada más. Cada contenedor —Personal,
Trabajo, Banco, Compras— puede salir por su propio proxy.
Tres piezas y ninguna alcanza sola: `atuq.cfg` prende los contenedores (vienen
apagados), `policies.json` los CREA con `Containers.Default` —el único
mecanismo que los pone en un perfil NUEVO— y deja la config de fábrica en
`3rdparty.Extensions`, y `extensions/proxy/` los enruta con `proxy.onRequest`,
que es lo único que ve el `cookieStoreId` de la petición.
Las cuatro se comprobaron DENTRO del artefacto de firefox antes de escribir una
línea, no en la documentación de Mozilla: `Containers` y `3rdparty` en el
`policies-schema.json` de browser/omni.ja, `cookieStoreId` en el
`schemas/proxy.json` de omni.ja, y `storage.managed` leyendo
`getExtensionPolicy(id)` en ext-storage.js. Es la regla del §2.sexies: la
pregunta no es si Mozilla lo tiene, es si NUESTRO build lo tiene.
Tres decisiones que valen más que el código:
1. La config se indexa por NOMBRE de contenedor y no por `cookieStoreId`: el id
depende del orden en que se crearon, así que la misma configuración aplicada
a otro perfil apuntaría a otro contenedor.
2. FAIL CLOSED. Un contenedor con proxy configurado que no se pudo honrar no
sale directo: va a un destino cerrado y el navegador muestra el error. Salir
directo sería una fuga silenciosa — la misma familia que el artefacto vacío
de la regla 3, el fallo que llega hasta el final diciendo que todo fue bien.
3. `proxyDNS` PRENDIDO por defecto: sin él la consulta DNS sale por la línea que
se quería evitar. Es la fuga clásica de esta configuración.
Y lo que no promete está arriba de todo en la página de opciones, no en un pie:
separación de tráfico, NO anonimato; para anonimato, Tor Browser. El §4 cumplido
donde el usuario lo lee.
De paso, una verdad que estaba escrita en dos sitios pasa a tener un dueño: el
id de cada extensión sale ahora del `manifest.json` y no de una constante de
rebrand.py, el nombre del XPI se deriva de él, y el `install_url` de la política
se cruza contra la ruta donde el fichero quedó escrito de verdad. El icono se
inyecta desde branding/icons/ en vez de estar copiado byte a byte dentro de cada
extensión. Agregar una tercera extensión es ahora un directorio.
PROBADO, corriendo el árbol en la misma jaula que atuq-nested.sh:
· captura de about:preferences#containers con los cuatro contenedores y sus
iconos, más el containers.json del perfil;
· extensions.json del perfil nombra las dos extensiones;
· `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — leyó la config
de fábrica por storage.managed Y la casó con los contenedores de la política;
· scripts/test-atuq-politica.py: cinco formas de desincronizar política y XPI,
las cinco matan el build, y el control con la política intacta pasa.
NO probado y dicho por su nombre: que una petición hecha en «Banco» salga por el
proxy de «Banco». Pide automatizar la UI y queda pendiente.
Dos obstáculos del método, que valen para la próxima. La consola de una
extensión es CONTENIDO: `devtools.console.stdout.chrome` (que viene en true) no
la incluye, hace falta `...stdout.content`. Y un `moz-extension://` NO se abre
desde la línea de comandos —muere con `NS_NOINTERFACE [nsIFileURL.file]` y abre
la home en su lugar—, además de que `--screenshot` dispara al `load`, que puede
ocurrir antes de que arranquen las extensiones. Eso último destapó un fallo real
y arreglado: la página de opciones confundía «no hay contenedores» con «la API
no está» y mostraba un mensaje FALSO.
Verificado contra firefox b3:352d7880; se reconstruye contra el firefox con
RLBox cuando selle. La otra mitad de esta unidad —el rename de
recipes/atuq/extension/ a extensions/inicio/— entró sin querer en ff0b556, que
es de otro frente.
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 42e18465..587eefdf 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -269,7 +269,7 @@ La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
| 6.5 | **Foco por `cortafuegos`, no por extensión** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `pacha` | 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** | nadie de fábrica | — (API de Firefox) | 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
@@ -309,6 +309,74 @@ eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de `
**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.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 |
+| 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 |
+
+**Lo que NO está probado, dicho por su nombre:** que una petición hecha en el contenedor «Banco»
+salga por el proxy de «Banco». Eso pide automatizar la UI —una pestaña de contenedor no se abre
+desde la línea de comandos— y queda pendiente en vez de darse por hecho.
+
+**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
@@ -461,7 +529,8 @@ siguiente.
| 6 | `sct` v1 (extensión + testigo) | el diferenciador que nadie tiene | 5 |
| 7 | Descargas al CAS | 6.2, y alimenta 6.9 | 5 |
| 8 | Archivo + RAG | 6.3 | 5, 7 |
-| 9 | Proxy por contenedor, torrent, medios, foco | 6.5–6.9 | 5 |
+| 9 | Torrent, medios, foco | 6.5–6.7, 6.9 | 5 |
+| 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.
diff --git a/recipes/atuq.toml b/recipes/atuq.toml
index fb615319..dcceb1da 100644
--- a/recipes/atuq.toml
+++ b/recipes/atuq.toml
@@ -31,8 +31,28 @@
# normalizado a 2010-01-01: Mozilla también persigue reproducibilidad. El re-empaque preserva
# orden, método y fecha — el orden porque es el que Gecko lee al arrancar, y es lo que el
# `jarlog` del PGO refinará cuando exista.
+# ══ v0.5: PROXY POR CONTENEDOR (SDD 26 §6.8) ═══════════════════════════════════════════════════
+# El primer DIFERENCIADOR de la lista del §6 que se paga entero acá, sin depender del host de
+# native messaging (§7) ni de nada de tawasuyu: es API de Firefox y nada más. Cada contenedor
+# —Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
+#
+# Son tres piezas y ninguna alcanza sola:
+# · `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que vienen apagados;
+# · `distribution/policies.json` los CREA con `Containers.Default` —el único mecanismo que los
+# pone en un perfil nuevo— y deja la config de fábrica del proxy en `3rdparty.Extensions`;
+# · `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el
+# `cookieStoreId` de la petición.
+#
+# Las tres se comprobaron DENTRO del artefacto de firefox antes de escribirlas, no en la
+# documentación de Mozilla: `Containers` y `3rdparty` están en el `policies-schema.json` de
+# `browser/omni.ja`, y `cookieStoreId` en el `schemas/proxy.json` de `omni.ja`. Es la regla que dejó
+# el §2.sexies: cuando la duda es «¿existe esto en NUESTRO build?», la respuesta está adentro.
+#
+# ⚠ LO QUE NO ES, Y VA ESCRITO EN LA PROPIA PÁGINA DE OPCIONES: separación de tráfico, NO anonimato.
+# No toca la huella del navegador. El §4 del SDD ya se había comprometido a decirlo primero.
name = "atuq"
-version = "0.2.0"
+# El campo se había quedado en 0.2.0 mientras los commits ya iban por v0.4; se pone al día acá.
+version = "0.5.0"
# El artefacto CONTIENE Firefox, así que hereda su licencia. El overlay de `recipes/atuq/` es
# nuestro, pero eso no cambia lo que se distribuye.
license = "MPL-2.0"
@@ -107,6 +127,15 @@ for f in defaults/pref/autoconfig.js atuq.cfg chrome/atuq.css distribution/polic
[ -s "$DST/$f" ] || { echo "atuq: $f quedó VACÍO o ausente" >&2; exit 1; }
echo " $f ($(wc -c < "$DST/$f") bytes)"
done
+# Las extensiones de sistema: que el XPI EXISTA y tenga bytes. `rebrand.py` ya cruzó cada id contra
+# `ExtensionSettings` y contra la ruta desde la que la política lo va a pedir; esto es el otro lado,
+# el del fichero. Un `install_url` correcto apuntando a un XPI de cero bytes falla en el arranque
+# del navegador, lejos de acá y con un mensaje que no nombra a esta receta.
+for x in "$DST"/distribution/extensions/*.xpi; do
+ [ -s "$x" ] || { echo "atuq: el XPI $x quedó vacío o no existe" >&2; exit 1; }
+ echo " $(basename "$x") ($(wc -c < "$x") bytes)"
+done
+
[ -x "$DST/atuq" ] || { echo "atuq: el binario renombrado no quedó ejecutable" >&2; exit 1; }
[ -x /out/usr/bin/atuq ] || { echo "atuq: el lanzador no quedó ejecutable" >&2; exit 1; }
grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no quedó rebrandeado" >&2; exit 1; }
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index ab76eea7..c7f1f3f0 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -13,8 +13,9 @@ hashea por CONTENIDO. Editar un fichero de acá mueve el `ArtifactHash`; no hay
| `prefs/autoconfig.js` | `defaults/pref/autoconfig.js` | Le dice a Gecko que lea `atuq.cfg`. Es el único gancho posible antes de que arranque el perfil. |
| `atuq.cfg` | `<appdir>/atuq.cfg` | Prefs de fábrica + carga la hoja de estilo del chrome. |
| `chrome/atuq.css` | `<appdir>/chrome/atuq.css` | El aspecto. Se inyecta como AGENT_SHEET desde `atuq.cfg`. |
-| `distribution/policies.json` | `<appdir>/distribution/policies.json` | Política de fábrica: telemetría, updates, **y la instalación de la extensión de inicio**. |
-| `extension/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
+| `distribution/policies.json` | `<appdir>/distribution/policies.json` | Política de fábrica: telemetría, updates, **la instalación de las extensiones**, los CONTENEDORES y la config de fábrica del proxy. |
+| `extensions/inicio/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
+| `extensions/proxy/` | `<appdir>/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). |
## Por qué la extensión se instala por POLÍTICA y no por carpeta
@@ -26,6 +27,46 @@ un fallo perfectamente silencioso.
El mecanismo vigente es `ExtensionSettings` en `policies.json`, con `install_url` apuntando al XPI
por ruta absoluta del FHS. Eso es estable acá porque el árbol se hidrata siempre en `/usr/lib/atuq`.
+## Una extensión nueva no cuesta nada, y su id tiene un solo dueño
+
+`extensions/<lo-que-sea>/` con un `manifest.json` adentro y ya: `rebrand.py` recorre el directorio,
+saca el id de `browser_specific_settings.gecko.id` —**no de una constante suya**— y escribe
+`<id>.xpi`. El icono lo inyecta desde `branding/icons/atuq128.png`, así que tampoco hay que copiarlo
+en cada extensión: la v0.4 tenía ese PNG duplicado byte a byte y con dos extensiones habrían sido
+tres copias que se desalinean en cuanto alguien actualice una sola.
+
+Y después **cruza los ids contra `policies.json`**, en los dos sentidos: una extensión empaquetada
+que la política no declara, o una política que apunta a un XPI que no existe, matan el build. Lo
+mismo con la ruta: el `install_url` tiene que ser exactamente donde el XPI quedó escrito. Antes eso
+era una constante contra otra constante; ahora una de las dos se DERIVA del fichero real.
+
+`scripts/test-atuq-politica.py` le pone delante las cinco formas conocidas de romper ese cruce y
+exige que las cinco maten el build (más un control con la política intacta, que tiene que pasar).
+
+## El proxy por contenedor (§6.8)
+
+Tres piezas, y ninguna alcanza sola:
+
+1. `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que Firefox trae apagados;
+2. `policies.json` los CREA con `Containers.Default` —el único mecanismo que los pone en un perfil
+ NUEVO— y deja la config de fábrica del proxy en `3rdparty.Extensions`, que es de dónde la
+ extensión lee su `storage.managed`;
+3. `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el `cookieStoreId`
+ de cada petición.
+
+La config se escribe por **nombre de contenedor** y no por `cookieStoreId`: el id depende del orden
+en que se crearon, así que la misma configuración aplicada a otro perfil apuntaría a otro
+contenedor. Dos capas: `storage.managed` es la de fábrica (la distro) y `storage.local` la del
+usuario, que pisa por contenedor.
+
+⚠ **Es separación de tráfico, NO anonimato**, y eso está escrito arriba de todo en la propia página
+de opciones, no en un pie de página. No toca la huella del navegador.
+
+**Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar no sale directo: va a
+un destino cerrado (127.0.0.1:9) y el navegador muestra el error. Salir directo sería una fuga
+silenciosa, que es la peor forma de este fallo. Y `proxyDNS` viene prendido: sin él la consulta DNS
+sale por la línea que se quería evitar.
+
## Por qué el CSS no es `userChrome.css`
`userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
@@ -55,7 +96,13 @@ y eso lo decide quien dibujó el zorro.
## Lo que TODAVÍA no está
-- **Re-empaque de `omni.ja`**: es donde va el chrome de verdad (split view, dientes propios). Trae
- dos condiciones ya sabidas: re-empacar determinista, y **preservar el orden del `jarlog`** cuando
- el PGO exista, o se tira a la basura la optimización de arranque.
+- **El chrome de verdad dentro de `omni.ja`** (split view, dientes propios). El re-empaque
+ determinista ya está resuelto y probado; lo que falta es el contenido. Sigue en pie la condición
+ 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.
diff --git a/recipes/atuq/atuq.cfg b/recipes/atuq/atuq.cfg
index ea148586..44afd70a 100644
--- a/recipes/atuq/atuq.cfg
+++ b/recipes/atuq/atuq.cfg
@@ -12,6 +12,25 @@
defaultPref("sidebar.revamp", true);
defaultPref("sidebar.verticalTabs", true);
+// ── LOS CONTENEDORES, PRENDIDOS DE FÁBRICA (SDD 26 §6.8) ───────────────────────────────────────
+// Firefox trae los contenedores en el motor desde hace años y los deja APAGADOS: para usarlos hay
+// que prender una pref escondida o instalar la extensión de Mozilla. atuq los prende de fábrica
+// porque son la mitad de abajo del proxy por contenedor —la que separa cookies, sesión y
+// almacenamiento—; la otra mitad, la que separa por dónde SALEN los paquetes, la pone la extensión
+// `proxy@atuq.tawasuyu`.
+//
+// Los contenedores CONCRETOS (Personal/Trabajo/Banco/Compras) no se declaran acá sino en
+// `distribution/policies.json`, con la política `Containers.Default`: es el único mecanismo que
+// crea contenedores en un perfil NUEVO sin que el usuario los escriba a mano. Comprobado dentro del
+// artefacto, no en la documentación: `ContextualIdentityService.sys.mjs` los lee de
+// `Services.policies.getActivePolicies().Containers.Default` en su `init()`.
+//
+// ⚠ `privacy.userContext.enabled` es la pref que ese mismo servicio observa: si alguna vez pasa a
+// false, Gecko CIERRA las pestañas de contenedor y borra la lista. Va como `defaultPref` igual que
+// todo lo demás —el usuario puede apagarlo—, pero apagarlo no es cosmético y por eso queda dicho.
+defaultPref("privacy.userContext.enabled", true);
+defaultPref("privacy.userContext.ui.enabled", true);
+
// La distro se actualiza con hammer. Un navegador que se auto-actualiza por su cuenta rompería el
// invariante entero: el binario dejaría de ser el que el artefacto dice que es.
defaultPref("app.update.auto", false);
diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json
index 4fe60e28..49994ed2 100644
--- a/recipes/atuq/distribution/policies.json
+++ b/recipes/atuq/distribution/policies.json
@@ -7,10 +7,34 @@
"DisableProfileImport": false,
"OverrideFirstRunPage": "",
"OverridePostUpdatePage": "",
+ "Containers": {
+ "Default": [
+ { "name": "Personal", "icon": "fingerprint", "color": "orange" },
+ { "name": "Trabajo", "icon": "briefcase", "color": "blue" },
+ { "name": "Banco", "icon": "dollar", "color": "green" },
+ { "name": "Compras", "icon": "cart", "color": "pink" }
+ ]
+ },
+ "3rdparty": {
+ "Extensions": {
+ "proxy@atuq.tawasuyu": {
+ "proxies": {
+ "Personal": { "type": "direct" },
+ "Trabajo": { "type": "direct" },
+ "Banco": { "type": "direct" },
+ "Compras": { "type": "direct" }
+ }
+ }
+ }
+ },
"ExtensionSettings": {
"inicio@atuq.tawasuyu": {
"installation_mode": "normal_installed",
"install_url": "file:///usr/lib/atuq/distribution/extensions/inicio@atuq.tawasuyu.xpi"
+ },
+ "proxy@atuq.tawasuyu": {
+ "installation_mode": "normal_installed",
+ "install_url": "file:///usr/lib/atuq/distribution/extensions/proxy@atuq.tawasuyu.xpi"
}
}
}
diff --git a/recipes/atuq/extensions/proxy/fondo.js b/recipes/atuq/extensions/proxy/fondo.js
new file mode 100644
index 00000000..3495a310
--- /dev/null
+++ b/recipes/atuq/extensions/proxy/fondo.js
@@ -0,0 +1,208 @@
+// atuq — proxy por contenedor (SDD 26 §6.8).
+//
+// QUÉ HACE: cada contenedor de Firefox («contextual identity») puede salir por su propio proxy.
+// El contenedor «Banco» por un SOCKS de la oficina, el «Compras» directo, y ninguno de los dos se
+// entera del otro. Ningún navegador trae esto de fábrica: en todos hace falta instalar una
+// extensión de terceros, que es exactamente el eslabón que una distro no debería pedirle al usuario.
+//
+// ⚠ LO QUE ESTO **NO** ES, Y VA PRIMERO A PROPÓSITO (SDD 26 §4): esto es **separación de tráfico,
+// no anonimato**. Cambia por dónde salen los paquetes; no toca la huella del navegador, ni el
+// user-agent, ni los timers, ni el canvas. Un sitio sigue reconociendo el mismo atuq del otro lado
+// de dos proxies distintos. Para anonimato existe Tor Browser y lo decimos nosotros primero.
+//
+// ── POR QUÉ MV2 CON FONDO PERSISTENTE ─────────────────────────────────────────────────────────
+// Un event page se descarga cuando está ocioso y se despierta con el evento. Acá eso significaría
+// reconstruir el mapa nombre→contenedor DENTRO del camino crítico de la primera petición, con la
+// resolución del proxy esperando. Persistente cuesta unos MB y quita ese riesgo entero.
+
+// ── EL MURO ───────────────────────────────────────────────────────────────────────────────────
+// Adónde se manda un contenedor que TIENE proxy configurado y no lo pudimos honrar (entrada
+// inválida, API que falla, mapa a medio construir). No es «directo»: si alguien pidió que ese
+// contenedor no salga por su línea, la respuesta correcta a un fallo nuestro es que NO SALGA, y que
+// se vea. El puerto 9 es `discard`, cerrado en cualquier máquina normal ⇒ el navegador muestra un
+// error de proxy en la cara en vez de mandar el tráfico por donde el usuario no quería.
+//
+// Es la misma postura que el resto del repo: un ausente falla ruidosamente, un vacío llega hasta el
+// final diciendo que todo fue bien. Acá el «vacío» sería una fuga silenciosa.
+const MURO = { type: "socks", host: "127.0.0.1", port: 9, proxyDNS: true };
+const DIRECTO = { type: "direct" };
+
+const TIPOS = new Set(["direct", "http", "https", "socks", "socks4"]);
+
+// Estado, reconstruible entero: nombre de contenedor → ProxyInfo ya validado.
+let porNombre = new Map();
+// cookieStoreId → ProxyInfo. Es el que consulta el listener; se recalcula cuando cambia cualquiera
+// de los dos lados (la config o la lista de contenedores).
+let porContenedor = new Map();
+
+function aviso(msg, e) {
+ console.error(`atuq/proxy: ${msg}${e ? ` — ${e}` : ""}`);
+}
+
+// ── VALIDACIÓN ────────────────────────────────────────────────────────────────────────────────
+// Devuelve el ProxyInfo, o `null` si la entrada no sirve. Nunca «arregla» una entrada a medias:
+// una entrada rota y una entrada ausente son cosas distintas y el llamador las trata distinto —la
+// ausente es directo, la rota es el MURO—, así que confundirlas acá borraría esa diferencia.
+function validar(entrada) {
+ if (!entrada || typeof entrada !== "object") return null;
+ const tipo = entrada.type;
+ if (!TIPOS.has(tipo)) return null;
+ if (tipo === "direct") return DIRECTO;
+ const host = entrada.host;
+ const port = Number(entrada.port);
+ if (typeof host !== "string" || !host.trim()) return null;
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
+ const info = { type: tipo, host: host.trim(), port };
+ // `proxyDNS` sólo existe para SOCKS, y NO es cosmético: sin él el navegador resuelve el nombre
+ // por su cuenta ANTES de hablar con el proxy, así que la consulta DNS sale por la línea que se
+ // quería evitar. Es la fuga clásica de este tipo de configuración; por eso el default es `true`
+ // y hay que apagarlo a mano, no al revés.
+ if (tipo === "socks" || tipo === "socks4") {
+ info.proxyDNS = entrada.proxyDNS !== false;
+ }
+ return info;
+}
+
+// ── LA CONFIGURACIÓN: DOS CAPAS ───────────────────────────────────────────────────────────────
+// `storage.managed` es la de FÁBRICA y la pone la distro por política (`3rdparty.Extensions` en
+// `distribution/policies.json`); `storage.local` es la del USUARIO y pisa a la anterior por
+// contenedor. Es la misma postura que `defaultPref` en `atuq.cfg`: la distro propone un arranque,
+// no una cárcel.
+//
+// `storage.managed.get()` LANZA «Managed storage manifest not found» cuando no hay política —no
+// devuelve vacío—, así que el catch no es decorativo: sin él, no tener política rompería la capa
+// del usuario también.
+async function leerConfig() {
+ const fusion = new Map();
+ const invalidas = [];
+
+ const capas = [];
+ try {
+ capas.push(await browser.storage.managed.get("proxies"));
+ } catch (e) {
+ // Normal en un perfil sin política; se anota en la consola y se sigue.
+ console.info(`atuq/proxy: sin configuración de fábrica (${e})`);
+ }
+ try {
+ capas.push(await browser.storage.local.get("proxies"));
+ } catch (e) {
+ aviso("no pude leer storage.local", e);
+ }
+
+ for (const capa of capas) {
+ const proxies = capa && capa.proxies;
+ if (!proxies || typeof proxies !== "object") continue;
+ for (const [nombre, entrada] of Object.entries(proxies)) {
+ const info = validar(entrada);
+ if (info) {
+ fusion.set(nombre, info);
+ } else {
+ // Se RECUERDA la entrada rota en vez de ignorarla: el contenedor queda al MURO, que es lo
+ // que la vuelve visible. Ignorarla lo dejaría saliendo directo, que es la fuga.
+ fusion.set(nombre, MURO);
+ invalidas.push(nombre);
+ }
+ }
+ }
+ if (invalidas.length) {
+ aviso(`entradas inválidas, esos contenedores quedan al muro: ${invalidas.join(", ")}`);
+ }
+ return fusion;
+}
+
+// ── EL MAPA CONTRA LOS CONTENEDORES REALES ────────────────────────────────────────────────────
+// La configuración se escribe por NOMBRE («Banco») y no por `cookieStoreId`
+// («firefox-container-3»), por dos razones: el nombre es lo que el usuario ve y lo que la política
+// de la distro declara, y el id depende del ORDEN en que se crearon los contenedores, o sea que la
+// misma config aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de
+// significado entre máquinas no sirve para configurar una distro.
+async function reconstruir() {
+ const mapa = new Map();
+ let identidades = [];
+ try {
+ identidades = await browser.contextualIdentities.query({});
+ } catch (e) {
+ // Pasa si los contenedores están apagados (`privacy.userContext.enabled=false`). Sin
+ // contenedores no hay nada que enrutar y el listener manda todo directo, que es correcto: no
+ // hay tráfico «de un contenedor» que pueda fugarse.
+ aviso("no pude listar los contenedores", e);
+ }
+ const sinContenedor = new Set(porNombre.keys());
+ for (const id of identidades) {
+ const info = porNombre.get(id.name);
+ if (info) {
+ mapa.set(id.cookieStoreId, info);
+ sinContenedor.delete(id.name);
+ }
+ }
+ porContenedor = mapa;
+ if (sinContenedor.size) {
+ // No es un error: se puede configurar un contenedor que todavía no existe. Pero es la causa
+ // número uno de «configuré el proxy y no pasa nada», así que se dice por su nombre.
+ console.info(
+ `atuq/proxy: configurados sin contenedor que les corresponda: ${[...sinContenedor].join(", ")}`
+ );
+ }
+ console.info(`atuq/proxy: ${mapa.size} contenedor(es) enrutado(s)`);
+}
+
+// `listo` es la promesa del arranque. El listener la espera: así no hay ventana en la que una
+// petición temprana se resuelva con el mapa a medio llenar.
+let listo = recargar();
+
+async function recargar() {
+ porNombre = await leerConfig();
+ await reconstruir();
+}
+
+// ── EL LISTENER, REGISTRADO YA ────────────────────────────────────────────────────────────────
+// Se registra en el primer tick, ANTES de que la configuración esté leída, y a propósito: si se
+// registrara después del `await`, las peticiones de ese intervalo saldrían directas sin pasar por
+// acá. Registrado desde el principio, esas peticiones simplemente esperan a `listo`.
+//
+// ⚠ Lo que esto NO cierra, dicho: las peticiones que Gecko haga ANTES de que la extensión arranque
+// (arranque en frío, actualización de la extensión) no ven este listener. Es un límite del
+// mecanismo de extensión, no de esta implementación; cerrarlo del todo exige que el ruteo viva en
+// el motor. Se anota acá porque un límite que no está escrito se lee como que no existe.
+browser.proxy.onRequest.addListener(
+ async details => {
+ try {
+ await listo;
+ } catch (e) {
+ aviso("el arranque falló", e);
+ // Si ni siquiera pudimos leer la configuración no sabemos si este contenedor tenía proxy ⇒
+ // no se puede afirmar que salir directo sea seguro, pero tampoco se puede mandar al muro el
+ // navegador entero. Se manda al muro sólo lo que tiene contenedor, que es de lo único que
+ // alguien pudo haber pedido separación.
+ return esDeContenedor(details) ? MURO : DIRECTO;
+ }
+ const id = details.cookieStoreId;
+ if (!id) return DIRECTO;
+ return porContenedor.get(id) || DIRECTO;
+ },
+ { urls: ["<all_urls>"] }
+);
+
+function esDeContenedor(details) {
+ return typeof details.cookieStoreId === "string" && details.cookieStoreId.startsWith("firefox-container-");
+}
+
+// `onError` es lo que convierte «no anda» en un mensaje. Sin esto, un fallo del propio resolvedor
+// de proxy no aparece en ningún lado.
+browser.proxy.onError.addListener(e => aviso("el resolvedor falló", e));
+
+// Los contenedores se crean, se renombran y se borran en caliente, y la config está indexada por
+// NOMBRE ⇒ renombrar uno cambia a qué proxy corresponde. Se reconstruye el mapa en los tres casos.
+for (const ev of ["onCreated", "onUpdated", "onRemoved"]) {
+ browser.contextualIdentities[ev].addListener(() => {
+ listo = reconstruir();
+ });
+}
+
+// La página de opciones escribe en `storage.local`; esto es lo que hace que el cambio valga sin
+// reiniciar el navegador.
+browser.storage.onChanged.addListener((cambios, area) => {
+ if (area === "local" && "proxies" in cambios) {
+ listo = recargar();
+ }
+});
diff --git a/recipes/atuq/extensions/proxy/manifest.json b/recipes/atuq/extensions/proxy/manifest.json
new file mode 100644
index 00000000..548a80e6
--- /dev/null
+++ b/recipes/atuq/extensions/proxy/manifest.json
@@ -0,0 +1,27 @@
+{
+ "manifest_version": 2,
+ "name": "atuq proxy por contenedor",
+ "version": "1.0",
+ "description": "Cada contenedor sale por su propio proxy. Separación de tráfico, NO anonimato.",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "proxy@atuq.tawasuyu",
+ "strict_min_version": "128.0"
+ }
+ },
+ "icons": { "128": "atuq128.png" },
+ "permissions": [
+ "proxy",
+ "storage",
+ "contextualIdentities",
+ "<all_urls>"
+ ],
+ "background": {
+ "scripts": ["fondo.js"],
+ "persistent": true
+ },
+ "options_ui": {
+ "page": "opciones.html",
+ "open_in_tab": true
+ }
+}
diff --git a/recipes/atuq/extensions/proxy/opciones.html b/recipes/atuq/extensions/proxy/opciones.html
new file mode 100644
index 00000000..a33dce07
--- /dev/null
+++ b/recipes/atuq/extensions/proxy/opciones.html
@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+<meta charset="utf-8">
+<title>atuq — proxy por contenedor</title>
+<style>
+/* Misma paleta que `chrome/atuq.css` y que `inicio.html`, muestreada del medallón. Duplicada por
+ la misma razón que allá: cada página vive en su propio mundo y no comparten hoja. Está anotado
+ en el README para que no se desincronicen en silencio. */
+:root {
+ --naranja: #c15728;
+ --teal: #1b6470;
+ --noche: #04161d;
+ --crema: #f5ecde;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0; padding: 2.2rem 1.4rem 4rem;
+ background: var(--noche); color: var(--crema);
+ font: 15px/1.55 system-ui, sans-serif;
+}
+main { width: min(860px, 94vw); margin: 0 auto; }
+h1 { margin: 0 0 .3rem; font-size: 1.7rem; font-weight: 600; }
+h1 img { width: 34px; height: 34px; vertical-align: -8px; margin-right: .5rem; }
+.sub { margin: 0 0 1.6rem; color: color-mix(in srgb, var(--crema) 62%, transparent); }
+
+/* La advertencia NO es un pie de página: va arriba, antes de la primera perilla, porque una
+ promesa falsa de privacidad es peor que no ofrecer nada (SDD 26 §4). */
+.aviso {
+ border: 1px solid color-mix(in srgb, var(--naranja) 55%, transparent);
+ background: color-mix(in srgb, var(--naranja) 12%, transparent);
+ border-radius: 10px; padding: .9rem 1.1rem; margin: 0 0 1.8rem;
+}
+.aviso b { color: var(--naranja); }
+
+table { width: 100%; border-collapse: collapse; }
+th, td { text-align: left; padding: .55rem .5rem; vertical-align: middle; }
+th { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em;
+ color: color-mix(in srgb, var(--crema) 55%, transparent); font-weight: 600; }
+tbody tr { border-top: 1px solid color-mix(in srgb, var(--crema) 14%, transparent); }
+.nombre { display: flex; align-items: center; gap: .55rem; font-weight: 600; }
+.punto { width: .7rem; height: .7rem; border-radius: 50%; flex: none; }
+.fabrica { font-size: .72rem; font-weight: 400; padding: .1rem .45rem; border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--teal) 70%, var(--crema));
+ color: color-mix(in srgb, var(--crema) 80%, transparent); }
+
+select, input {
+ font: inherit; color: var(--crema);
+ background: color-mix(in srgb, var(--crema) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--crema) 26%, transparent);
+ border-radius: 7px; padding: .38rem .55rem; outline: none;
+}
+select:focus, input:focus { border-color: var(--naranja); box-shadow: 0 0 0 3px color-mix(in srgb, var(--naranja) 26%, transparent); }
+input.host { width: 13rem; }
+input.port { width: 5.5rem; }
+input:disabled, select:disabled { opacity: .38; }
+td.dns { text-align: center; }
+
+footer { margin-top: 1.8rem; display: flex; align-items: center; gap: 1rem; }
+button {
+ font: inherit; font-weight: 600; color: var(--noche); background: var(--naranja);
+ border: 0; border-radius: 8px; padding: .55rem 1.3rem; cursor: pointer;
+}
+button.secundario { color: var(--crema); background: transparent;
+ border: 1px solid color-mix(in srgb, var(--crema) 30%, transparent); }
+button:focus-visible { outline: 2px solid var(--crema); outline-offset: 2px; }
+#estado { color: color-mix(in srgb, var(--crema) 70%, transparent); }
+#vacio { padding: 1.4rem 0; color: color-mix(in srgb, var(--crema) 62%, transparent); }
+code { background: color-mix(in srgb, var(--crema) 10%, transparent); padding: .1rem .3rem; border-radius: 4px; }
+</style>
+</head>
+<body>
+<main>
+ <h1><img src="atuq128.png" alt="">Proxy por contenedor</h1>
+ <p class="sub">Cada contenedor sale por su propia línea. Lo que hagas en uno no viaja por la del otro.</p>
+
+ <p class="aviso">
+ <b>Esto es separación de tráfico, no anonimato.</b> Cambia por dónde salen los paquetes; no
+ cambia la huella del navegador. Un sitio reconoce el mismo atuq del otro lado de dos proxies
+ distintos. Si lo que buscás es anonimato, lo honesto es decirte que uses <b>Tor Browser</b>, y
+ te lo decimos nosotros primero.
+ </p>
+
+ <table>
+ <thead>
+ <tr><th>Contenedor</th><th>Salida</th><th>Servidor</th><th>Puerto</th><th class="dns">DNS por el proxy</th></tr>
+ </thead>
+ <tbody id="filas"></tbody>
+ </table>
+ <p id="vacio" hidden>
+ No hay contenedores en este perfil. Se habilitan en <code>about:preferences#containers</code>;
+ atuq trae los suyos declarados en <code>distribution/policies.json</code>.
+ </p>
+
+ <footer>
+ <button id="guardar">Guardar</button>
+ <button id="restablecer" class="secundario">Volver a los de fábrica</button>
+ <span id="estado" role="status" aria-live="polite"></span>
+ </footer>
+</main>
+<script src="opciones.js"></script>
+</body>
+</html>
diff --git a/recipes/atuq/extensions/proxy/opciones.js b/recipes/atuq/extensions/proxy/opciones.js
new file mode 100644
index 00000000..44744c13
--- /dev/null
+++ b/recipes/atuq/extensions/proxy/opciones.js
@@ -0,0 +1,186 @@
+// Página de opciones del proxy por contenedor.
+//
+// DOS CAPAS, IGUAL QUE EN `fondo.js`: lo que la distro deja de fábrica llega por
+// `storage.managed` (política de empresa, `3rdparty.Extensions` en `distribution/policies.json`) y
+// es de SÓLO LECTURA para esta página; lo que el usuario decide se guarda en `storage.local` y pisa
+// a lo anterior contenedor por contenedor. «Volver a los de fábrica» no reescribe valores: BORRA la
+// capa del usuario, que es lo único que deja el resultado igual a un perfil recién creado.
+
+const TIPOS = [
+ ["direct", "Directo (sin proxy)"],
+ ["socks", "SOCKS5"],
+ ["socks4", "SOCKS4"],
+ ["http", "HTTP"],
+ ["https", "HTTPS"],
+];
+
+const filas = document.getElementById("filas");
+const vacio = document.getElementById("vacio");
+const estado = document.getElementById("estado");
+
+function decir(msg, error) {
+ estado.textContent = msg;
+ estado.style.color = error ? "#c15728" : "";
+}
+
+async function leerCapa(area, nombre) {
+ try {
+ const r = await browser.storage[area].get("proxies");
+ return (r && r.proxies) || {};
+ } catch (e) {
+ // `storage.managed` LANZA cuando no hay política; no es un fallo, es la ausencia de la capa.
+ console.info(`atuq/proxy: sin capa ${nombre} (${e})`);
+ return {};
+ }
+}
+
+function fila(identidad, deFabrica, delUsuario) {
+ const valor = delUsuario || deFabrica || { type: "direct" };
+ const tr = document.createElement("tr");
+ tr.dataset.nombre = identidad.name;
+
+ const tdNombre = document.createElement("td");
+ const cont = document.createElement("div");
+ cont.className = "nombre";
+ const punto = document.createElement("span");
+ punto.className = "punto";
+ // `colorCode` viene del propio contenedor: el punto de esta tabla y la raya de la pestaña son el
+ // mismo color porque salen del mismo sitio, no porque los hayamos igualado a ojo.
+ punto.style.background = identidad.colorCode || "#888";
+ cont.append(punto, document.createTextNode(identidad.name));
+ if (deFabrica && !delUsuario) {
+ const et = document.createElement("span");
+ et.className = "fabrica";
+ et.textContent = "de fábrica";
+ et.title = "Viene de la política de la distro; si lo cambiás, tu valor manda.";
+ cont.append(et);
+ }
+ tdNombre.append(cont);
+
+ const tdTipo = document.createElement("td");
+ const sel = document.createElement("select");
+ for (const [v, etiqueta] of TIPOS) {
+ const o = document.createElement("option");
+ o.value = v;
+ o.textContent = etiqueta;
+ sel.append(o);
+ }
+ sel.value = TIPOS.some(([v]) => v === valor.type) ? valor.type : "direct";
+ tdTipo.append(sel);
+
+ const tdHost = document.createElement("td");
+ const host = document.createElement("input");
+ host.type = "text";
+ host.className = "host";
+ host.placeholder = "127.0.0.1";
+ host.value = valor.host || "";
+ tdHost.append(host);
+
+ const tdPort = document.createElement("td");
+ const port = document.createElement("input");
+ port.type = "number";
+ port.className = "port";
+ port.min = "1";
+ port.max = "65535";
+ port.placeholder = "1080";
+ port.value = valor.port || "";
+ tdPort.append(port);
+
+ const tdDns = document.createElement("td");
+ tdDns.className = "dns";
+ const dns = document.createElement("input");
+ dns.type = "checkbox";
+ // Prendido por defecto y no apagado: sin esto el navegador resuelve el nombre por su cuenta antes
+ // de hablar con el proxy y la consulta DNS sale 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.
+ dns.checked = valor.proxyDNS !== false;
+ dns.title = "Resolver los nombres en el proxy y no en esta máquina. Sólo aplica a SOCKS.";
+ tdDns.append(dns);
+
+ const sincronizar = () => {
+ const directo = sel.value === "direct";
+ const socks = sel.value === "socks" || sel.value === "socks4";
+ host.disabled = directo;
+ port.disabled = directo;
+ dns.disabled = !socks;
+ };
+ sel.addEventListener("change", sincronizar);
+ sincronizar();
+
+ tr.append(tdNombre, tdTipo, tdHost, tdPort, tdDns);
+ tr.leer = () => {
+ const tipo = sel.value;
+ if (tipo === "direct") return { type: "direct" };
+ const p = Number(port.value);
+ if (!host.value.trim()) throw new Error(`«${identidad.name}»: falta el servidor`);
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
+ throw new Error(`«${identidad.name}»: el puerto tiene que estar entre 1 y 65535`);
+ }
+ const info = { type: tipo, host: host.value.trim(), port: p };
+ if (tipo === "socks" || tipo === "socks4") info.proxyDNS = dns.checked;
+ return info;
+ };
+ return tr;
+}
+
+async function pintar() {
+ filas.textContent = "";
+
+ // ── «NO HAY CONTENEDORES» Y «NO HAY API» SE PARECEN Y NO SON LO MISMO ────────────────────────
+ // Medido, no supuesto: abriendo esta página mientras el arranque de las extensiones todavía corre
+ // —pasa en un arranque en frío, y pasó de verdad en la prueba headless— el global `browser` aún
+ // no está inyectado. Sin este corte, cada `await` de abajo tira `ReferenceError`, los catch lo
+ // tragan y la página termina diciendo «No hay contenedores en este perfil»: un mensaje FALSO que
+ // manda a mirar al sitio equivocado. Un ausente tiene que fallar ruidoso.
+ if (typeof browser === "undefined" || !browser.contextualIdentities) {
+ vacio.hidden = true;
+ decir("Las APIs de la extensión todavía no están listas — recargá la página en un momento.", true);
+ return;
+ }
+ const [deFabrica, delUsuario] = await Promise.all([
+ leerCapa("managed", "de fábrica"),
+ leerCapa("local", "del usuario"),
+ ]);
+
+ let identidades = [];
+ try {
+ identidades = await browser.contextualIdentities.query({});
+ } catch (e) {
+ decir(`No pude listar los contenedores: ${e}`, true);
+ }
+ vacio.hidden = identidades.length > 0;
+ for (const id of identidades) {
+ filas.append(fila(id, deFabrica[id.name], delUsuario[id.name]));
+ }
+
+ // Un proxy configurado para un contenedor que no existe es la causa número uno de «lo configuré y
+ // no pasa nada» — casi siempre porque el contenedor se renombró. Se dice acá, no en la consola.
+ const nombres = new Set(identidades.map(i => i.name));
+ const huerfanos = [...new Set([...Object.keys(deFabrica), ...Object.keys(delUsuario)])]
+ .filter(n => !nombres.has(n));
+ if (huerfanos.length) {
+ decir(`Configurados sin contenedor que les corresponda: ${huerfanos.join(", ")}`, true);
+ }
+}
+
+document.getElementById("guardar").addEventListener("click", async () => {
+ const proxies = {};
+ try {
+ for (const tr of filas.children) proxies[tr.dataset.nombre] = tr.leer();
+ } catch (e) {
+ // No se guarda NADA si una fila está mal. Guardar las buenas y saltar la rota dejaría al
+ // usuario creyendo que guardó, con un contenedor saliendo por donde no quería.
+ decir(e.message, true);
+ return;
+ }
+ await browser.storage.local.set({ proxies });
+ decir("Guardado. Vale para las peticiones nuevas, sin reiniciar.");
+});
+
+document.getElementById("restablecer").addEventListener("click", async () => {
+ await browser.storage.local.remove("proxies");
+ await pintar();
+ decir("Listo: quedó lo que trae la distro de fábrica.");
+});
+
+pintar();
diff --git a/recipes/atuq/tools/rebrand.py b/recipes/atuq/tools/rebrand.py
index c53d2322..773d9664 100644
--- a/recipes/atuq/tools/rebrand.py
+++ b/recipes/atuq/tools/rebrand.py
@@ -21,6 +21,7 @@ igual de bien: sería exactamente la clase de fallo que llega hasta el final dic
bien (regla 3 del CLAUDE.md).
"""
import hashlib
+import json
import os
import sys
import zipfile
@@ -228,48 +229,140 @@ def reemplazos_branding():
return r
-# ── 4.bis La extensión de sistema: página de inicio y pestaña nueva ────────────────────────────
-# POR QUÉ UNA EXTENSIÓN Y NO UNA PREF. `browser.startup.homepage` acepta una URL, pero la pestaña
-# NUEVA no se puede redirigir con una pref desde hace años: el único mecanismo soportado es
-# `chrome_url_overrides.newtab`. Y una página servida por `file://` mostraría una ruta del disco en
-# la barra de direcciones, que es feo y además frágil (depende de dónde quedó hidratado el árbol).
-# La extensión resuelve las dos —`chrome_url_overrides` y `chrome_settings_overrides.homepage`— con
-# una sola pieza y sin URLs absolutas.
+# ── 4.bis Las extensiones de sistema ──────────────────────────────────────────────────────────
+# POR QUÉ EXTENSIONES Y NO PREFS. La página de inicio se podría poner con
+# `browser.startup.homepage`, pero la pestaña NUEVA no se puede redirigir con una pref desde hace
+# años: el único mecanismo soportado es `chrome_url_overrides.newtab`. Y el ruteo por proxy de cada
+# contenedor (SDD 26 §6.8) directamente no tiene forma de pref: es la API `proxy.onRequest`, que
+# sólo existe para una extensión.
#
# Esto sólo funciona porque `recipes/firefox.toml` construye con
-# `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0`.
-# Son tres piezas en tres ficheros distintos y **las tres tienen que estar**: sin la primera Gecko
-# rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO.
+# `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0` y
+# `xpinstall.signatures.required = false`. Son piezas en ficheros distintos y **todas tienen que
+# estar**: sin la primera Gecko rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO.
#
# EL ZIP VA `STORED` Y CON FECHA FIJA, igual que el `omni.ja`: un XPI comprimido metería la versión
-# de zlib como variable del artefacto, y son cuatro ficheros de texto — no hay nada que ahorrar.
-EXT_ID = "inicio@atuq.tawasuyu"
-
+# de zlib como variable del artefacto, y son ficheros de texto — no hay nada que ahorrar.
+#
+# ── EL ID SALE DEL MANIFIESTO, NO DE UNA CONSTANTE DE ACÁ ─────────────────────────────────────
+# La v0.4 tenía el id escrito en dos sitios: `EXT_ID` en este fichero y
+# `browser_specific_settings.gecko.id` en el manifiesto. Dos dueños para un valor es la forma
+# conocida de que se desincronicen, y el fallo que producen es de los caros: el XPI se escribiría
+# con un nombre y Gecko, al abrirlo, encontraría otro id adentro ⇒ la política de instalación
+# apuntaría a un fichero que para Firefox no es el complemento que pidió. Ahora hay un solo dueño
+# —el manifiesto— y el nombre del fichero se DERIVA de él.
+EXT_DIR = "extensions"
+# El icono se INYECTA desde `branding/icons/` en vez de vivir copiado dentro de cada extensión. La
+# v0.4 tenía `extension/atuq128.png` byte a byte igual a `branding/icons/atuq128.png`; con dos
+# extensiones habrían sido tres copias del mismo PNG en el repo, y la marca se desalinea el día que
+# alguien actualiza una y no las otras.
+EXT_ICONO = "atuq128.png"
+
+
+def _leer_id(manifiesto, donde):
+ try:
+ datos = json.loads(manifiesto)
+ except ValueError as e:
+ fatal(f"{donde}: el manifest.json no es JSON válido — {e}")
+ try:
+ ext_id = datos["browser_specific_settings"]["gecko"]["id"]
+ except (KeyError, TypeError):
+ fatal(f"{donde}: el manifest.json no declara browser_specific_settings.gecko.id")
+ if not isinstance(ext_id, str) or "@" not in ext_id:
+ fatal(f"{donde}: el id {ext_id!r} no parece un id de extensión")
+ return ext_id
+
+
+def empaquetar_extensiones():
+ base = os.path.join(OVERLAY, EXT_DIR)
+ if not os.path.isdir(base):
+ fatal(f"no está el árbol de las extensiones en {base}")
+ dirs = sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d)))
+ if not dirs:
+ fatal(f"{base} no tiene ninguna extensión")
+
+ with open(os.path.join(OVERLAY, "branding", "icons", EXT_ICONO), "rb") as fh:
+ icono = fh.read()
-def empaquetar_extension():
- origen = os.path.join(OVERLAY, "extension")
- if not os.path.isdir(origen):
- fatal(f"no está el árbol de la extensión en {origen}")
- ficheros = sorted(os.listdir(origen))
- if "manifest.json" not in ficheros:
- fatal("la extensión no tiene manifest.json")
destino_dir = os.path.join(APPDIR, "distribution", "extensions")
os.makedirs(destino_dir, exist_ok=True)
- destino = os.path.join(destino_dir, f"{EXT_ID}.xpi")
- with zipfile.ZipFile(destino, "w") as z:
- for nombre in ficheros:
+
+ resultados = []
+ for d in dirs:
+ origen = os.path.join(base, d)
+ ficheros = {}
+ for nombre in sorted(os.listdir(origen)):
ruta = os.path.join(origen, nombre)
if not os.path.isfile(ruta):
continue
with open(ruta, "rb") as fh:
datos = fh.read()
if not datos:
- fatal(f"la extensión trae {nombre} VACÍO")
- zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0))
- zi.compress_type = zipfile.ZIP_STORED
- zi.external_attr = 0o644 << 16
- z.writestr(zi, datos)
- return f"{EXT_ID}.xpi ({len(ficheros)} ficheros, {os.path.getsize(destino)} bytes)"
+ fatal(f"la extensión {d} trae {nombre} VACÍO")
+ ficheros[nombre] = datos
+ if "manifest.json" not in ficheros:
+ fatal(f"la extensión {d} no tiene manifest.json")
+ ficheros[EXT_ICONO] = icono
+
+ ext_id = _leer_id(ficheros["manifest.json"], d)
+ destino = os.path.join(destino_dir, f"{ext_id}.xpi")
+ # Orden alfabético y no el del `listdir`: el orden del directorio depende del sistema de
+ # ficheros, y un zip cuyas entradas cambian de orden entre máquinas ya no reproduce.
+ with zipfile.ZipFile(destino, "w") as z:
+ for nombre in sorted(ficheros):
+ zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0))
+ zi.compress_type = zipfile.ZIP_STORED
+ zi.external_attr = 0o644 << 16
+ z.writestr(zi, ficheros[nombre])
+ # La ruta con la que la POLÍTICA va a pedir este XPI, derivada de dónde lo acabamos de
+ # escribir. Es lo que hace que el cruce de abajo compruebe algo de verdad y no una constante
+ # contra otra constante.
+ url = "file:///" + os.path.relpath(destino, OUTROOT)
+ resultados.append((d, ext_id, len(ficheros), os.path.getsize(destino), url))
+ return resultados
+
+
+# ── 4.ter El cruce contra la política ─────────────────────────────────────────────────────────
+# El sideloading desde `distribution/extensions/` ya no existe en Firefox: el XPI se instala porque
+# `policies.json` lo declara en `ExtensionSettings` con un `install_url` ABSOLUTO. O sea que el
+# fichero y la política tienen que coincidir en id Y en ruta, y cuando no coinciden el fallo es
+# silencioso —el complemento sencillamente no aparece— o llega como un `Download failed` que no
+# nombra la causa. Se cruza acá, en el build, donde falla gratis.
+def verificar_politica(resultados):
+ ruta = os.path.join(APPDIR, "distribution", "policies.json")
+ try:
+ with open(ruta, encoding="utf-8") as fh:
+ pol = json.load(fh)["policies"]
+ except (OSError, ValueError, KeyError) as e:
+ fatal(f"no pude leer {ruta}: {e}")
+
+ ajustes = pol.get("ExtensionSettings", {})
+ declarados = {k for k in ajustes if k != "*"}
+ empaquetados = {r[1] for r in resultados}
+
+ faltan = empaquetados - declarados
+ if faltan:
+ fatal(f"empaquetadas pero NO declaradas en ExtensionSettings: {sorted(faltan)}")
+ sobran = declarados - empaquetados
+ if sobran:
+ fatal(f"declaradas en ExtensionSettings sin XPI que las respalde: {sorted(sobran)}")
+
+ for _, ext_id, _, _, url in resultados:
+ esperada = ajustes[ext_id].get("install_url")
+ if esperada != url:
+ fatal(f"{ext_id}: la política instala desde {esperada!r} y el XPI quedó en {url!r}")
+
+ # Los contenedores de la política son la mitad de abajo del §6.8: sin ellos, la extensión del
+ # proxy arranca, no encuentra a qué contenedor aplicar nada y queda inerte sin decir por qué.
+ contenedores = [c.get("name") for c in pol.get("Containers", {}).get("Default", [])]
+ terceros = pol.get("3rdparty", {}).get("Extensions", {})
+ for ext_id, conf in terceros.items():
+ if ext_id not in empaquetados:
+ fatal(f"3rdparty configura {ext_id}, que no es ninguna extensión de atuq")
+ huerfanos = sorted(set(conf.get("proxies", {})) - set(contenedores))
+ if huerfanos:
+ fatal(f"{ext_id}: proxies para contenedores que la política no crea: {huerfanos}")
+ return contenedores
# ── 5. Los binarios ────────────────────────────────────────────────────────────────────────────
@@ -334,7 +427,8 @@ def main():
binarios = renombrar_binarios()
# Después del renombrado: los ficheros ya se llaman atuq/atuq-bin.
parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")]
- ext = empaquetar_extension()
+ exts = empaquetar_extensiones()
+ contenedores = verificar_politica(exts)
print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}")
print(f"rebrand: application.ini {sorted(claves)}")
@@ -344,7 +438,9 @@ def main():
print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})")
for l in parches:
print(f"rebrand: {l}")
- print(f"rebrand: extensión de sistema {ext}")
+ for d, ext_id, n, size, _ in exts:
+ print(f"rebrand: extensión de sistema {ext_id}.xpi ← {EXT_DIR}/{d} ({n} ficheros, {size} bytes)")
+ print(f"rebrand: política cruzada — {len(exts)} extensión(es), contenedores {contenedores}")
main()
diff --git a/scripts/test-atuq-politica.py b/scripts/test-atuq-politica.py
new file mode 100755
index 00000000..67387762
--- /dev/null
+++ b/scripts/test-atuq-politica.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Ejercita los guardianes de `recipes/atuq/tools/rebrand.py` con políticas ROTAS a propósito.
+
+ python3 scripts/test-atuq-politica.py # sale 0 si todos los guardianes hacen su trabajo
+
+POR QUÉ EXISTE. `atuq` instala sus extensiones por POLÍTICA (`ExtensionSettings` en
+`distribution/policies.json`), no por sideloading —Firefox lo retiró—, así que el XPI y la política
+tienen que coincidir en id y en RUTA ABSOLUTA. Cuando no coinciden, el fallo es silencioso: el
+complemento sencillamente no aparece, o llega un `Download failed` que no nombra la causa. Los
+guardianes que cruzan las dos cosas viven en `rebrand.py`; esto comprueba QUE FALLEN.
+
+Un guardián que nunca falló no se sabe si sirve. Cada caso de abajo es una forma conocida de
+desincronizarlas, y el control con la política intacta está primero para que un guardián que muera
+SIEMPRE —que es igual de inútil— también se note.
+
+No construye nada ni toca el store: arma un appdir de mentira en un temporal y llama a las dos
+funciones directamente.
+"""
+import json, os, pathlib, shutil, sys, tempfile
+
+REPO = str(pathlib.Path(__file__).resolve().parent.parent)
+os.chdir(REPO)
+FUENTE = open("recipes/atuq/tools/rebrand.py").read().replace("\nmain()\n", "\n")
+
+def correr(mutar):
+ raiz = tempfile.mkdtemp()
+ app = os.path.join(raiz, "usr", "lib", "atuq")
+ os.makedirs(os.path.join(app, "distribution"))
+ pol = json.load(open("recipes/atuq/distribution/policies.json"))
+ mutar(pol)
+ json.dump(pol, open(os.path.join(app, "distribution", "policies.json"), "w"))
+ ns = {"__name__": "rb"}
+ sys.argv = ["rebrand.py", app, os.path.join(REPO, "recipes/atuq"), raiz]
+ exec(compile(FUENTE, "rebrand.py", "exec"), ns)
+ try:
+ ns["verificar_politica"](ns["empaquetar_extensiones"]())
+ except SystemExit:
+ return "MURIÓ"
+ finally:
+ shutil.rmtree(raiz, ignore_errors=True)
+ return "pasó"
+
+# Cada caso: qué se rompe, cómo, y qué tiene que pasar.
+CASOS = [
+ ("intacta (control: tiene que PASAR)", lambda p: None, "pasó"),
+ ("id empaquetado que la política no declara",
+ lambda p: p["policies"]["ExtensionSettings"].pop("proxy@atuq.tawasuyu"), "MURIÓ"),
+ ("install_url apuntando a otro sitio",
+ lambda p: p["policies"]["ExtensionSettings"]["proxy@atuq.tawasuyu"].__setitem__(
+ "install_url", "file:///usr/lib/atuq/otro.xpi"), "MURIÓ"),
+ ("política que declara un XPI que no existe",
+ lambda p: p["policies"]["ExtensionSettings"].__setitem__(
+ "nadie@atuq.tawasuyu", {"install_url": "file:///x.xpi"}), "MURIÓ"),
+ ("proxy para un contenedor que la política no crea",
+ lambda p: p["policies"]["3rdparty"]["Extensions"]["proxy@atuq.tawasuyu"]["proxies"]
+ .__setitem__("Fantasma", {"type": "direct"}), "MURIÓ"),
+ ("3rdparty para una extensión que no es de atuq",
+ lambda p: p["policies"]["3rdparty"]["Extensions"].__setitem__(
+ "ajena@example.com", {"proxies": {}}), "MURIÓ"),
+]
+
+fallos = 0
+for nombre, mutar, esperado in CASOS:
+ obtenido = correr(mutar)
+ ok = obtenido == esperado
+ fallos += not ok
+ print(f" [{'ok' if ok else 'FALLA'}] {nombre}: esperaba {esperado}, dio {obtenido}")
+print("guardianes:", "todos correctos" if not fallos else f"{fallos} MAL")
+sys.exit(1 if fallos else 0)