1# atuq — el envoltorio Gecko de la distro
2
3`atuq` (quechua: *zorro*) es el navegador de la distro: **un artefacto DERIVADO de `firefox`**, no un
4fork de su fuente. El diseño completo está en [`docs/26-atuq-envoltorio-gecko.md`](../../docs/26-atuq-envoltorio-gecko.md).
5
6Este directorio ES la fuente de atuq — `recipes/atuq.toml` lo declara con `source.dir` y takana lo
7hashea por CONTENIDO. Editar un fichero de acá mueve el `ArtifactHash`; no hay commit que pinear.
8
9⚠ **Y ESTE README también entra en el hash.** O sea que escribir acá el resultado de una medición
10invalida el artefacto sobre el que se midió: el hash que un párrafo cita es siempre el
11PREDECESOR del vigente. Es esperable y no es deuda —el rebuild son 6 s mientras el `firefox`
12vigente esté sellado—, pero hay que saberlo antes de leer un hash de acá y pensar que el fichero
13quedó viejo. Los guardianes de `scripts/test-atuq-*.py` se NIEGAN a medir un artefacto que no sea
14el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y cada reselle deja
15340 M más en el store: conviene juntar las ediciones en una.
16
17## Qué hay acá y dónde cae
18
19| Acá | En el artefacto | Qué hace |
20|---|---|---|
21| `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. |
22| `atuq.cfg` | `<appdir>/atuq.cfg` | Prefs de fábrica + carga la hoja de estilo del chrome. |
23| `chrome/atuq.css` | `<appdir>/chrome/atuq.css` | El aspecto. Se registra como **USER_SHEET** desde `atuq.cfg` — el mismo nivel de cascada que `userChrome.css`, para que el usuario pueda pisarlo. (Decía AGENT_SHEET; el fichero nunca lo usó, y AGENT_SHEET además alcanzaría al contenido de las páginas.) |
24| `distribution/policies.json` | `<appdir>/distribution/policies.json` | Política de fábrica: telemetría, updates, el MODO y la CONFIG de las extensiones (no su instalación — ver más abajo), los CONTENEDORES y la config de fábrica del proxy. |
25| `extensions/inicio/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
26| `extensions/proxy/` | `<appdir>/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). |
27| `extensions/sct/` | `<appdir>/distribution/extensions/sct@atuq.tawasuyu.xpi` | **Transparencia de scripts** (SDD 26 §6.1). |
28| `extensions/descargas/` | `<appdir>/distribution/extensions/descargas@atuq.tawasuyu.xpi` | **Descargas con identidad** (SDD 26 §6.2). |
29| `extensions/archivo/` | `<appdir>/distribution/extensions/archivo@atuq.tawasuyu.xpi` | **Archivo personal** (SDD 26 §6.3). |
30| `extensions/medios/` | `<appdir>/distribution/extensions/medios@atuq.tawasuyu.xpi` | **Medios fuera del navegador** (SDD 26 §6.6). |
31| `extensions/torrent/` | `<appdir>/distribution/extensions/torrent@atuq.tawasuyu.xpi` | **Torrent con identidad** (SDD 26 §6.9). |
32| `extensions/foco/` | `<appdir>/distribution/extensions/foco@atuq.tawasuyu.xpi` | **Modo foco: lo muestra, no lo apaga** (SDD 26 §6.5). |
33| `extensions/ia/` | `<appdir>/distribution/extensions/ia@atuq.tawasuyu.xpi` | **IA local en la barra lateral** (SDD 26 §6.7). |
34| `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
35| `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
36
37## Quién instala las extensiones — y este README decía lo contrario
38
39**Las instala el ESCANEO de `distribution/extensions/`.** `ExtensionSettings` de la política las
40FIJA y las CONFIGURA; instalarlas, no.
41
42⚠ Esta sección decía justo lo contrario —que el *sideloading* desde la carpeta «ya no funciona» y
43que instalaba la política— y era **falso**. Los dos mecanismos apuntan a los MISMOS ficheros, así
44que ninguna observación del artefacto los distingue y la política *parece* ser la que instala. Para
45saberlo hay que romper uno por vez, y eso es lo que hace `scripts/test-atuq-instalacion.py`
46(tres escenarios, medidos el 2026-09-09 sobre `atuq b3:fab2fbfb` y repetidos sobre `b3:9e16ac35`):
47
48| escenario | qué se rompe | resultado |
49|---|---|---|
50| `as-is` | nada | las dos extensiones puestas; la home es la nuestra |
51| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **siguen puestas** y la home sigue siendo la nuestra ⇒ instala la carpeta |
52| `policy-only` | los XPI mudados fuera de la carpeta, con la política apuntando a la ruta nueva | **no aparecen** y la home vuelve a `about:home` ⇒ `install_url` con `file://` no instala nada |
53
54En las tres corridas hay un CONTROL por construcción: una extensión sonda que vive en la carpeta y
55que **ninguna política nombra**. Si la sonda habla, el escaneo está vivo en esa corrida; una corrida
56muda se declara rota en vez de leerse como «no instaló».
57
58Consecuencias prácticas para quien agregue una extensión:
59
601. el XPI **tiene que quedar en `distribution/extensions/`** — es lo único que la instala;
612. la entrada en `ExtensionSettings` no es opcional por eso: es la que la deja `normal_installed`
62 (el usuario la puede quitar) y la que le entrega su `storage.managed` por `3rdparty.Extensions`;
633. `extensions.autoDisableScopes = 0` en `atuq.cfg` es lo que evita que el escaneo las instale
64 **desactivadas**;
654. el `location` que `extensions.json` le pone al complemento **no dice de qué mecanismo salió**:
66 medido, los instalados por la carpeta también salen `app-profile`.
67
68## Una extensión nueva no cuesta nada, y su id tiene un solo dueño
69
70`extensions/<lo-que-sea>/` con un `manifest.json` adentro y ya: `rebrand.py` recorre el directorio,
71saca el id de `browser_specific_settings.gecko.id` —**no de una constante suya**— y escribe
72`<id>.xpi`. El icono lo inyecta desde `branding/icons/atuq128.png`, así que tampoco hay que copiarlo
73en cada extensión: la v0.4 tenía ese PNG duplicado byte a byte y con dos extensiones habrían sido
74tres copias que se desalinean en cuanto alguien actualice una sola.
75
76Y después **cruza los ids contra `policies.json`**, en los dos sentidos: una extensión empaquetada
77que la política no declara, o una política que apunta a un XPI que no existe, matan el build. Lo
78mismo con la ruta: el `install_url` tiene que ser exactamente donde el XPI quedó escrito. Antes eso
79era una constante contra otra constante; ahora una de las dos se DERIVA del fichero real.
80
81`scripts/test-atuq-politica.py` le pone delante las cinco formas conocidas de romper ese cruce y
82exige que las cinco maten el build (más un control con la política intacta, que tiene que pasar).
83
84## El proxy por contenedor (§6.8)
85
86Tres piezas, y ninguna alcanza sola:
87
881. `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que Firefox trae apagados;
892. `policies.json` los CREA con `Containers.Default` —el único mecanismo que los pone en un perfil
90 NUEVO— y deja la config de fábrica del proxy en `3rdparty.Extensions`, que es de dónde la
91 extensión lee su `storage.managed`;
923. `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el `cookieStoreId`
93 de cada petición.
94
95La config se escribe por **nombre de contenedor** y no por `cookieStoreId`: el id depende del orden
96en que se crearon, así que la misma configuración aplicada a otro perfil apuntaría a otro
97contenedor. Dos capas: `storage.managed` es la de fábrica (la distro) y `storage.local` la del
98usuario, que pisa por contenedor.
99
100⚠ **Es separación de tráfico, NO anonimato**, y eso está escrito arriba de todo en la propia página
101de opciones, no en un pie de página. No toca la huella del navegador.
102
103**Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar no sale directo: va a
104un destino cerrado (127.0.0.1:9) y el navegador muestra el error. Salir directo sería una fuga
105silenciosa, que es la peor forma de este fallo. Y `proxyDNS` viene prendido: sin él la consulta DNS
106sale por la línea que se quería evitar.
107
108## `sct` — transparencia de scripts (§6.1)
109
110La joya del §6, y la primera pieza de atuq que habla con la suite. Qué hace: por cada carga de
111página, la extensión intercepta el cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa
112al host nativo; el host —`puriy-costura`, que por dentro es `puriy-sct`— lo hashea con BLAKE3, aprende
113el conjunto de código estable de cada origen (TOFU) y **avisa cuando un sitio ya estable ejecuta código
114que nadie vio nunca**. Eso es el síntoma de un CDN envenenado, una cadena de suministro comprometida o
115un script dirigido a un usuario, y no lo tiene ningún navegador.
116
117**La extensión no hashea ni guarda nada.** Ve bytes y hace preguntas; el registro es del núcleo del
118otro lado del cable. Duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y
119el primero es el que está certificado sin red.
120
121 servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
122 → puriy-costura --state → puriy-sct (TOFU + bitácora fork-proof)
123
124### Cinco cosas de esta cadena que no se pueden deducir, y por eso se midieron
125
1261. **El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, NO en el appdir.** Gecko saca esa
127 ruta de `XRESysNativeManifests`, que en Linux es un `/usr/lib/mozilla` **compilado**: el rebranding
128 a `atuq` no lo mueve. Y nadie más en el corpus escribe ahí (comprobado sobre el store), así que la
129 ruta es de atuq.
1302. **El manifiesto NO puede llevar argumentos.** En `omni.ja`, `modules/NativeMessaging.sys.mjs` hace
131 `let command = hostInfo.manifest.path` y le pasa sólo `[ruta-del-manifiesto, id-de-la-extensión]`.
132 Como `puriy-costura` sin `--state` corre todo en memoria —y entonces cada arranque del navegador
133 volvería a «aprendiendo» y nada alertaría nunca—, hace falta el lanzador `bin/puriy-costura-host`.
134 Es la peor forma de fallo: la función existe, no falla, y no protege de nada.
1353. **`filterResponseData` y el permiso `webRequestFilterResponse` existen en NUESTRO build** (están en
136 `omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`). Se preguntó al artefacto
137 antes de escribir la extensión, no a la documentación de Mozilla.
1384. **Los scripts `inline` no se ven por esta vía**, y no es un agujero disimulado: `filterResponseData`
139 entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML. El ataque que `sct` nombra es
140 la sustitución en el CDN, o sea exactamente el caso `<script src>`. Los inline los ve la v2, con el
141 gancho en el script loader de Gecko.
1425. **Una carga de página puede producir MÁS DE UNA petición del mismo documento, y una llega con
143 `tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
144 visitas. No es cosmético: inflar las visitas **estabiliza el origen antes de tiempo**, y un origen
145 estabilizado antes de conocer su código real alerta por churn legítimo — el falso positivo que la
146 spec de `puriy-sct` pide evitar por encima de todo. Ahora se agrupa por documento, y
147 `scripts/test-atuq-sct.py` **vigila el invariante**: una carga, una visita.
148
149### v1 observa y avisa; no bloquea — y eso es del diseño
150
151El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2. Acá se observa
152y se avisa por dos razones: `puriy-sct` es explícito en que su v1 **alerta y no bloquea** (bloquear
153necesita antes la acción del usuario, que es su M1), y poner un viaje ida-y-vuelta entre procesos en el
154camino crítico de cada script de cada página no es «más seguro»: es un navegador que nadie usa.
155
156El aviso es **pasivo**: una insignia en la barra con el número de scripts nuevos y, en el tooltip, sus
157nombres. No un modal — un modal por cada despliegue entrena a la gente a cerrarlo sin leer, y entonces
158el que importa también se cierra.
159
160⚠ **Y el límite que hay que decir primero:** se hashea el TEXTO ya decodificado que la extensión
161entrega, no los bytes que sirvió el servidor. La detección vale —el TOFU compara lo mismo contra lo
162mismo—, pero ese hash **no es comparable** con el que publique un tercero sobre los bytes servidos.
163
164`scripts/test-atuq-sct.py` mide la cadena entera con un servidor HTTP real y seis cargas de página, y
165trae dos controles: la quinta carga repite el MISMO script ya estable y **no** debe alertar, y
166`--negative-control` borra el manifiesto y exige que no haya veredicto — o sea que el veredicto de la
167corrida positiva viene del host y no de la extensión inventándolo.
168
169## `descargas` — una descarga con identidad (§6.2)
170
171Cuando una descarga TERMINA, la extensión le pasa al host la ruta, el nombre y de dónde salió; el host
172la ingiere a un CAS BLAKE3 y contesta el hash, el tamaño y **cuántas veces se bajó ese mismo
173contenido**. El mismo contenido con otro nombre es **un objeto y dos nombres**, y el navegador lo dice.
174Ningún navegador trata una descarga como algo con identidad: para todos es un blob con nombre, y por
175eso lo bajás dos veces y no lo sabés.
176
177No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
178`tejido`.
179
180⚠ **Tres cosas que hay que saber, y las tres están medidas:**
181
1821. **La raíz del CAS del navegador NO es la del sistema.** `arje_cas::gc` borra todo blob que no esté
183 en el set `reachable` de su llamador, y el único que existe hoy (`arje-brain`, `GcCas`) lo arma con
184 la cadena de audit y las raíces vivas del grafo. Una descarga no está en ninguno: **el primer
185 `GcCas` se la llevaría, en silencio.** Por eso van a `<estado>/cas`. El precio, dicho:
186 `tejido` sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo a esa raíz.
187 ⚠ Se llamaba `descargas-cas` hasta que entró el §6.3: ahora el archivo personal y las descargas
188 comparten **un** CAS, que es lo que hace que una página archivada y un fichero bajado con el mismo
189 contenido sean **un solo objeto**. El guardián de descargas se quedó mirando el nombre viejo y
190 estuvo rojo dos días sin que nadie lo corriera.
1912. **El fichero que bajaste no se toca.** Se copia al CAS y se deja donde estaba. El guardián lo
192 comprueba byte a byte.
1933. **La extensión observa, no intercepta.** Se entera cuando la descarga terminó. Meterse en el medio
194 obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual—
195 es exactamente lo que se consigue no metiéndose.
196
197`scripts/test-atuq-descargas.py` baja de verdad (`Content-Disposition: attachment`) dos veces y exige
198`dedup=true` la segunda; con `--negative-control` la segunda descarga es otro contenido y exige lo
199contrario.
200
201## `archivo` — el archivo personal (§6.3)
202
203Cada página que leés se congela: el **HTML ya ejecutado** —lo que viste, no lo que mandó el
204servidor— va al CAS con su BLAKE3, y la url, el título y el texto visible al índice. Después se
205busca. Volver a la misma página **no la duplica**; la misma URL con otro contenido **es otra
206página**, así que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
207punteros se pudren.
208
209**Lo que NO se archiva es la parte que hay que mirar:**
210
2111. **nada de una ventana privada** — se comprueba en el fondo y no en el guión de contenido, porque
212 `tabs.get()` es lo único que sabe si la pestaña lo es; y **si no se puede saber, no se archiva**.
213 ⚠ Medido: hoy el que impide archivar en privado es **el navegador** (las extensiones no corren
214 ahí salvo que se las habilite), así que nuestro `incognito` no se ejecuta nunca — y es justo lo
215 que haría seguro habilitar `private_browsing` el día que haga falta;
2162. nada fuera del marco principal: un `<iframe>` de publicidad no es una página que alguien leyó;
2173. nada sin texto visible: el HTML de un visor de PDF llenaría el archivo de cosas que no se
218 encuentran.
219
220⚠ **`archive.search` es el registro LITERAL** —todas las palabras de la consulta tienen que aparecer
221en la url, el título o el texto indexado—. **No es preguntarle al historial en lenguaje natural**:
222eso es el registro semántico, un motor `rag-motor::RagMotor` como `willay-rag`, que necesita un
223daemon de embeddings y un LLM de verdad y devuelve `None` cuando no están. Cuando exista se enchufa
224del mismo lado y esto no cambia.
225
226`scripts/test-atuq-archivo.py` visita, revisita y busca; su control negativo abre la página en una
227ventana privada, exige que no se archive nada **y dice quién lo impidió** — nuestro código o el
228navegador—, porque las dos protegen pero sólo una es nuestra.
229
230## `medios` — el vídeo no se ve en una pestaña (§6.6)
231
232Cuando una navegación de **primer nivel** resulta ser un medio —el servidor contesta `video/*` o
233`audio/*`—, la navegación se cancela y la URL se la lleva `mpv`, que ya viaja en las cuatro imágenes
234de escritorio. El navegador vuelve a ser un navegador en vez de un reproductor a medias metido en una
235pestaña.
236
237**Sólo el primer nivel**: un `<video>` embebido es parte de la página, y sacarlo de ahí rompería el
238sitio que lo puso. Las reglas anchas en el camino de cada petición son las que terminan rompiendo la
239web de alguien.
240
241**Si no hay reproductor, la navegación VUELVE al navegador.** Cancelar es síncrono y la respuesta del
242host llega después, así que no se puede saber en el momento si arrancó; se cancela sólo con el puerto
243vivo y, ante un fallo, se reabre la URL con una marca para no entrar en bucle. Quedarse sin vídeo y
244sin pestaña es el único resultado inaceptable, y el control negativo del guardián lo prueba.
245
246⚠ El binario **no viene del mensaje**: es una constante del host. Lo único que viaja es la URL, sólo
247`http(s)`, y detrás de un `--`.
248
249## `ia` — la barra lateral le pregunta a un modelo de ESTA máquina (§6.7)
250
251Sin nube, sin clave de API y sin un byte saliendo: el host levanta un `llama-server` (el `llama-cpp`
252del corpus) contra el modelo pineado en `/usr/share/takana/ia/modelo.gguf` y habla con él por un
253**socket UNIX**. No hay servicio de IA en la imagen: el primer `ai.ask` lo levanta, y **se muere con
254el navegador** — al revés que el daemon del torrent (§6.9), porque un torrent tiene trabajo que
255sobrevive y medio giga de modelo en RAM no.
256
257El puerto nativo vive en el **fondo**, no en la página del panel: si viviera en la página, cerrar la
258barra lateral se llevaría el proceso del host y el modelo cargado con ella. `panel.html?q=…` abre el
259panel con la pregunta ya hecha, y `?p=…` la manda **a tus páginas** en vez de al modelo.
260
261**Dos preguntas, dos botones, y no se mezclan a propósito.** «Al modelo» genera texto; «A mis
262páginas» ordena por parecido lo que ya leíste (§6.3, `archive.ask`) y muestra los títulos **en
263orden**, sin porcentaje: el puntaje es un coseno y leerlo como «85 % de acierto» sería inventarle un
264significado. Presentar las dos juntas haría imposible saber cuál contestó — una inventa y la otra
265recuerda.
266
267Cuando algo falta, el panel **muestra la causa** («no hay modelo en …», «no hay motor de inferencia
268en …»). Una IA que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario
269no puede distinguir «no hay modelo» de «el modelo dijo eso».
270
271`scripts/test-atuq-ia.py` mide la cadena entera con inferencia REAL, en una jaula `--unshare-net`, y
272comprueba además que al cerrarse el navegador queden **cero** `llama-server`. Control negativo: sin
273modelo, la causa aparece y **no hay respuesta inventada**.
274
275El modelo de chat es **Qwen2.5-1.5B-Instruct Q4_K_M** (`recipes/ia-modelo-chat.toml`, Apache-2.0,
2761,04 GiB, 20,1 tok/s en CPU, habla español — las tres cosas medidas antes de pinearlo), y va **en las
277cuatro imágenes de escritorio** junto con el motor: ~1,25 GiB por perfil.
278
279El de **embeddings** (`ia-modelo-embeddings`, Qwen3-Embedding-0.6B, 610 MiB) es **descarga opcional**:
280no está declarado en ningún perfil —decisión, no olvido— y se instala con
281`takana install ia-modelo-embeddings`. Hasta que se instale, «A mis páginas» contesta con esa misma
282línea en vez de con un «no está» sin salida.
283
284## `foco` — el navegador MUESTRA el foco, y no tiene con qué apagarlo (§6.5)
285
286El foco no es un bloqueador de sitios: es una regla `nft` de **egress por cgroup** que aplicó root, y
287mientras está puesta el navegador **no sale a la red** — con todo lo local andando (el archivo del
288§6.3 se lee, el CAS del §6.2 está). Medido en una máquina con root: el proceso del cgroup en foco no
289abre la conexión y el del cgroup de al lado sí (`scripts/test-foco-egress.sh`).
290
291Esta extensión es la mitad del navegador, y es **asimétrica a propósito: sólo lee**. Pregunta
292`focus.state` al host cada minuto —el estado lo cambia root por fuera, así que no hay evento al que
293suscribirse— y pinta tres cosas y ninguna más: `foco` si está puesto, nada si no, y **`?` si nadie
294escribió el estado**. Ese tercer caso es el que importa: si «no sé» se redondeara a «apagado», la
295insignia afirmaría que no hay foco sin haberlo mirado.
296
297**No hay verbo para apagarlo, y eso es la propiedad y no un pendiente.** Si desde el navegador se
298pudiera levantar, el modo foco valdría lo que vale un bloqueador de extensión: dos clics. En tawasuyu
299hay un test que lo fija (`focus.stop` → «verbo desconocido»), y acá el guardián mide el efecto:
300después de una sesión entera del navegador, el fichero de estado quedó **igual**.
301
302`scripts/test-atuq-foco.py` corre los tres estados y comprueba, por corrida, qué dijo la extensión,
303qué pintó y que el estado no cambió. Verificado rompiéndolo: con una extensión que pinta «foco» pase
304lo que pase, el guardián falla nombrando la insignia.
305
306Lo que todavía NO existe —y no se inventó de paso— es quién pone a `atuq` en un cgroup y quién aplica
307la política: lo primero pide delegación de un subárbol a la sesión (decisión de arje, SDD 10) y lo
308segundo pide root. Ver el §6.5 del SDD 26.
309
310## `torrent` — el magnet lo toma la suite (§6.9)
311
312La extensión registra un handler para `magnet:` (`protocol_handlers`, que existe en nuestro build),
313así que un clic abre **su propia página** con el enlace, y esa página se lo pasa al host. El host se
314lo entrega a `puriy-costura-torrent`, que es **otro proceso**.
315
316**Por qué otro proceso y no un verbo más**: una descarga tiene que sobrevivir al navegador, y el host
317no lo hace —Gecko lo mata al cerrarse el puerto—. Y librqbit son 226 crates: dentro del host, ese
318binario pasaría de 952 K a ~20 MB para las cinco extensiones que lo comparten.
319
320**Perezoso**: no hay servicio en la imagen. El binario está y no corre hasta que hay un torrent; lo
321levanta su cliente (con `setsid`, o se moriría con el navegador) y se va solo cuando no queda nada
322activo. Sembrar cuenta como actividad.
323
324⚠ **La página no valida la forma del origen**: la valida el host, que es quien decide qué le pasa al
325daemon. Tener la regla escrita dos veces es tenerla mintiendo el día que una cambie.
326
327`scripts/test-atuq-torrent.py` sirve un `.torrent` hecho a mano (un `magnet:` bloquearía esperando
328metadata que sin peers no llega) y comprueba que el daemon **aparezca sin que nadie lo arranque**. Su
329control negativo saca el cliente de la imagen y exige que el host lo diga en vez de fingirlo.
330
331## Por qué el CSS no es `userChrome.css`
332
333`userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
334`toolkit.legacyUserProfileCustomizations.stylesheets`. Una distro no puede depender de eso: el
335navegador tiene que verse como atuq la primera vez que arranca, con un perfil recién creado. La vía
336que sí funciona a nivel de aplicación es el `nsIStyleSheetService` desde autoconfig, que es lo que
337hace `atuq.cfg`.
338
339## La marca
340
341El arte viene de un tablero de identidad del operador (2026-09-05): un **zorro en medallón** con
342cenefa andina, sobre disco teal y anillo noche.
343
344- `branding/icons/atuq{16,32,48,64,128,256}.png` — el medallón recortado del tablero, con máscara
345 circular y alfa, reducido con LANCZOS desde un master de 1024 px. Se generan una vez y se copian;
346 no se dibujan en cada build.
347- La **paleta del chrome se muestreó del propio medallón**, tomando el color más frecuente de cada
348 zona, en vez de estimarla a ojo: `#c15728` el zorro, `#1b6470` el disco, `#04161d` el anillo,
349 `#f5ecde` los reflejos. Un token escrito «parecido» es cómo una identidad se desalinea de su logo
350 en tres ediciones.
351- **Del tablero se toman la forma y el color; los eslóganes NO** — el operador los descartó
352 explícitamente. `brand.ftl` lleva el nombre y nada más.
353
354⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un
355logo con cenefa, no un defecto del escalado: la salida sería un dibujo simplificado para ese tamaño,
356y eso lo decide quien dibujó el zorro.
357
358## El chrome se programa desde `atuq.cfg`, no abriendo `omni.ja`
359
360Esto era un pendiente («el chrome de verdad dentro de `omni.ja`») y resultó ser dos cosas falsas.
361Medido con `scripts/test-atuq-chrome.py` sobre `atuq b3:9e16ac35`:
362
3631. **No hace falta entrar al zip para programar el chrome.** `atuq.cfg` ya corre con privilegios —de
364 ahí sale `nsIStyleSheetService`—, así que desde ahí se observa `browser-delayed-startup-finished`
365 y se toca el `gBrowser` de CADA ventana. La sonda abre dos pestañas y las parte en dos, desde
366 `atuq.cfg`, sin tocar `omni.ja`.
367
368 Y es la vía que hay que usar, no un atajo: re-empacar `omni.ja` pelea con el orden del `jarlog`
369 del PGO —por eso el `jarlog` está aparcado—, así que **cada función nueva del chrome que NO entre
370 al zip es una que no compite con la optimización de arranque.**
371
3722. **La vista dividida ya la trae el motor, y prendida.** Firefox 154 lleva
373 `tabbrowser/tabsplitview.js` (492 líneas), `opentabs-splitview.mjs` y `split-view-footer.js`
374 dentro de `browser/omni.ja`, y `defaults/preferences/firefox.js` dice
375 `pref("browser.tabs.splitView.enabled", true)`. La sonda lo ejercita de verdad —
376 `gBrowser.addTabSplitView([t1, t2])` ⇒ `activeSplitView` puesta y `splitViewBrowsers.length == 2`
377 — así que no es «existe el fichero»: **funciona en nuestro build**. No hay nada que escribir acá.
378
379⚠ Lo que esto NO dice: atuq **todavía no lleva JS de chrome propio**. Lo medido es el GANCHO, con la
380sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que lo
381justifique, el fichero va en `chrome/atuq.js` y lo carga `atuq.cfg` igual que la hoja de estilo.
382
383El control negativo de la sonda sale del propio motor: `addTabSplitView` documenta que si TODAS las
384pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija y exige eso.
385
386## Lo que TODAVÍA no está
387
388- **La v2 de `sct`**: el gancho en el script loader de Gecko, que es lo único que ve los scripts
389 `inline` y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis).
390- **La mitad SEMÁNTICA del archivo (6.3)**: un motor `rag-motor::RagMotor` sobre el archivo, que
391 necesita el daemon de embeddings y un backend LLM en la imagen. `willay-rag` es el molde.
392- **La IA local en la barra lateral (6.7)**, que está bloqueada por algo medido: el corpus no tiene
393 ninguna receta de LLM ni de embeddings. Es también la que traería el motor que le falta al archivo.
394- **Que una descarga se pueda COMPARTIR con `tejido`**: hoy el objeto está en el CAS de descargas y
395 tejido sirve el del sistema. Falta que el índice de descargas sea una raíz que el GC respete, y ahí
396 las dos raíces pueden volver a ser una.
397*(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco»
398está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a
399qué puerto llama el navegador. Trae control negativo.)*
400
401## Instalar una extensión A MANO — y qué pasa con los datos de una «temporal»
402
403El `.xpi` de las diez sale sin construir el navegador:
404
405```sh
406python3 scripts/atuq-xpi.py # ⇒ work/atuq-xpi/<id>.xpi
407```
408
409**No es un empaquetador nuevo**: ese script ejecuta `empaquetar_extensiones()` de `tools/rebrand.py`,
410que es el mismo que corre en el build. Comprobado, y es la única comprobación que vale acá: los diez
411`.xpi` generados salen **byte a byte idénticos** a los de los dos artefactos `atuq` sellados del store
412(`5156cb8f…` y `e556024b…`, 10/10 iguales en los dos), y dos corridas seguidas dan los mismos bytes.
413Así que el que te instalás a mano ES el de la imagen, no una variante parecida.
414
415### Lo «temporal» de `about:debugging` se va, y se lleva sus datos
416
417Un complemento cargado con **«Cargar complemento temporal»** vive hasta que cerrás el navegador: al
418reiniciar queda *desinstalado*, y desinstalar un complemento borra su `storage.local` y su entrada en
419`extensions.webextensions.uuids`. O sea que **sí, contá con que lo que configuraste ahí se pierde** —
420y no vuelve instalándolo después con el mismo id, porque el `moz-extension://` UUID se regenera.
421
422⚠ Esto es comportamiento documentado de Gecko, **no medido en esta caja**: acá no corre ningún
423navegador (el firefox del corpus muere con `Error relocating /lib/libstdc++.so.6: arc4random`, y en el
424anfitrión no hay ninguno de glibc). Si hace falta certeza, se mide en la imagen, no acá.
425
426**De las diez, la única que tiene datos de usuario que perder es `proxy`** (medido: `storage.local`
427sólo aparece en `extensions/proxy/`). Las otras nueve son sin estado o lo guardan del lado nativo
428(`puriy_costura`), así que para ellas la pregunta es indiferente.
429
430Y `proxy` tiene una capa que la temporal **no** se lleva: sus cuatro contenedores de fábrica vienen de
431`storage.managed` por `3rdparty.Extensions` de la política, que es de sólo lectura y no vive en el
432perfil. Lo que se pierde es lo que pisaste vos desde la página de opciones.
433
434### Que quede fija
435
436| dónde | qué hace falta |
437|---|---|
438| **atuq** | nada más: `atuq.cfg:67` trae `lockPref("xpinstall.signatures.required", false)`, así que un `.xpi` sin firmar se instala permanente desde `about:addons` → *Instalar complemento desde archivo* |
439| **Firefox Developer Edition / Nightly / ESR** | poner `xpinstall.signatures.required = false` en `about:config` y después instalar desde archivo |
440| **Firefox release o beta** | **no se puede.** El requisito de firma está clavado en esos canales y el pref no lo levanta; la salida es usar otro canal o firmar el `.xpi` en AMO |
441
442Si en vez de a mano lo querés como lo hace la imagen —`distribution/extensions/` + entrada en
443`ExtensionSettings`— eso es la sección de arriba, y ahí el `installation_mode: normal_installed` es lo
444que además le entrega su `storage.managed`.
445
446⚠ **Ocho de las diez no sirven de nada fuera de la imagen.** `boveda`, `archivo`, `ia`, `descargas`,
447`torrent`, `sct`, `medios` y `foco` piden `nativeMessaging` contra `puriy_costura`, cuyo manifiesto
448apunta a `/usr/lib/atuq/puriy-costura-host`; sin ese binario **se instalan y se quedan mudas** — no
449fallan al instalar, que es justo la forma que no se nota. Las dos que andan solas son `proxy` e
450`inicio`.
README.md
atuq — el envoltorio Gecko de la distro
atuq (quechua: zorro) es el navegador de la distro: un artefacto DERIVADO de firefox, no un
fork de su fuente. El diseño completo está en docs/26-atuq-envoltorio-gecko.md.
Este directorio ES la fuente de atuq — recipes/atuq.toml lo declara con source.dir y takana lo
hashea por CONTENIDO. Editar un fichero de acá mueve el ArtifactHash; no hay commit que pinear.
⚠ Y ESTE README también entra en el hash. O sea que escribir acá el resultado de una medición
invalida el artefacto sobre el que se midió: el hash que un párrafo cita es siempre el
PREDECESOR del vigente. Es esperable y no es deuda —el rebuild son 6 s mientras el firefox
vigente esté sellado—, pero hay que saberlo antes de leer un hash de acá y pensar que el fichero
quedó viejo. Los guardianes de scripts/test-atuq-*.py se NIEGAN a medir un artefacto que no sea
el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y cada reselle deja
340 M más en el store: conviene juntar las ediciones en una.
Qué hay acá y dónde cae
| Acá | En el artefacto | Qué hace |
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 registra como USER_SHEET desde atuq.cfg — el mismo nivel de cascada que userChrome.css, para que el usuario pueda pisarlo. (Decía AGENT_SHEET; el fichero nunca lo usó, y AGENT_SHEET además alcanzaría al contenido de las páginas.) |
distribution/policies.json | <appdir>/distribution/policies.json | Política de fábrica: telemetría, updates, el MODO y la CONFIG de las extensiones (no su instalación — ver más abajo), 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). |
extensions/sct/ | <appdir>/distribution/extensions/sct@atuq.tawasuyu.xpi | Transparencia de scripts (SDD 26 §6.1). |
extensions/descargas/ | <appdir>/distribution/extensions/descargas@atuq.tawasuyu.xpi | Descargas con identidad (SDD 26 §6.2). |
extensions/archivo/ | <appdir>/distribution/extensions/archivo@atuq.tawasuyu.xpi | Archivo personal (SDD 26 §6.3). |
extensions/medios/ | <appdir>/distribution/extensions/medios@atuq.tawasuyu.xpi | Medios fuera del navegador (SDD 26 §6.6). |
extensions/torrent/ | <appdir>/distribution/extensions/torrent@atuq.tawasuyu.xpi | Torrent con identidad (SDD 26 §6.9). |
extensions/foco/ | <appdir>/distribution/extensions/foco@atuq.tawasuyu.xpi | Modo foco: lo muestra, no lo apaga (SDD 26 §6.5). |
extensions/ia/ | <appdir>/distribution/extensions/ia@atuq.tawasuyu.xpi | IA local en la barra lateral (SDD 26 §6.7). |
native-messaging/*.json | /usr/lib/mozilla/native-messaging-hosts/ | El manifiesto del host nativo. Fuera del appdir — ver abajo. |
bin/puriy-costura-host | <appdir>/puriy-costura-host | El lanzador del host: le pone el --state que el manifiesto no puede. |
Quién instala las extensiones — y este README decía lo contrario
Las instala el ESCANEO de distribution/extensions/. ExtensionSettings de la política las
FIJA y las CONFIGURA; instalarlas, no.
⚠ Esta sección decía justo lo contrario —que el sideloading desde la carpeta «ya no funciona» y
que instalaba la política— y era falso. Los dos mecanismos apuntan a los MISMOS ficheros, así
que ninguna observación del artefacto los distingue y la política parece ser la que instala. Para
saberlo hay que romper uno por vez, y eso es lo que hace scripts/test-atuq-instalacion.py
(tres escenarios, medidos el 2026-09-09 sobre atuq b3:fab2fbfb y repetidos sobre b3:9e16ac35):
| escenario | qué se rompe | resultado |
as-is | nada | las dos extensiones puestas; la home es la nuestra |
no-policy | policies.json sin ExtensionSettings | siguen puestas y la home sigue siendo la nuestra ⇒ instala la carpeta |
policy-only | los XPI mudados fuera de la carpeta, con la política apuntando a la ruta nueva | no aparecen y la home vuelve a about:home ⇒ install_url con file:// no instala nada |
En las tres corridas hay un CONTROL por construcción: una extensión sonda que vive en la carpeta y
que ninguna política nombra. Si la sonda habla, el escaneo está vivo en esa corrida; una corrida
muda se declara rota en vez de leerse como «no instaló».
Consecuencias prácticas para quien agregue una extensión:
- el XPI tiene que quedar en
distribution/extensions/ — es lo único que la instala;
- la entrada en
ExtensionSettings no es opcional por eso: es la que la deja normal_installed
(el usuario la puede quitar) y la que le entrega su storage.managed por 3rdparty.Extensions;
extensions.autoDisableScopes = 0 en atuq.cfg es lo que evita que el escaneo las instale
desactivadas;
- el
location que extensions.json le pone al complemento no dice de qué mecanismo salió:
medido, los instalados por la carpeta también salen app-profile.
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:
atuq.cfg prende los contenedores (privacy.userContext.enabled), que Firefox trae 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 del proxy en 3rdparty.Extensions, que es de dónde la
extensión lee su storage.managed;
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.
sct — transparencia de scripts (§6.1)
La joya del §6, y la primera pieza de atuq que habla con la suite. Qué hace: por cada carga de
página, la extensión intercepta el cuerpo de cada <script src> con filterResponseData y se lo pasa
al host nativo; el host —puriy-costura, que por dentro es puriy-sct— lo hashea con BLAKE3, aprende
el conjunto de código estable de cada origen (TOFU) y avisa cuando un sitio ya estable ejecuta código
que nadie vio nunca. Eso es el síntoma de un CDN envenenado, una cadena de suministro comprometida o
un script dirigido a un usuario, y no lo tiene ningún navegador.
La extensión no hashea ni guarda nada. Ve bytes y hace preguntas; el registro es del núcleo del
otro lado del cable. Duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y
el primero es el que está certificado sin red.
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
→ puriy-costura --state → puriy-sct (TOFU + bitácora fork-proof)
Cinco cosas de esta cadena que no se pueden deducir, y por eso se midieron
- El manifiesto va en
/usr/lib/mozilla/native-messaging-hosts/, NO en el appdir. Gecko saca esa
ruta de XRESysNativeManifests, que en Linux es un /usr/lib/mozilla compilado: el rebranding
a atuq no lo mueve. Y nadie más en el corpus escribe ahí (comprobado sobre el store), así que la
ruta es de atuq.
- El manifiesto NO puede llevar argumentos. En
omni.ja, modules/NativeMessaging.sys.mjs hace
let command = hostInfo.manifest.path y le pasa sólo [ruta-del-manifiesto, id-de-la-extensión].
Como puriy-costura sin --state corre todo en memoria —y entonces cada arranque del navegador
volvería a «aprendiendo» y nada alertaría nunca—, hace falta el lanzador bin/puriy-costura-host.
Es la peor forma de fallo: la función existe, no falla, y no protege de nada.
filterResponseData y el permiso webRequestFilterResponse existen en NUESTRO build (están en
omni.ja, chrome/toolkit/content/extensions/schemas/web_request.json). Se preguntó al artefacto
antes de escribir la extensión, no a la documentación de Mozilla.
- Los scripts
inline no se ven por esta vía, y no es un agujero disimulado: filterResponseData
entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML. El ataque que sct nombra es
la sustitución en el CDN, o sea exactamente el caso <script src>. Los inline los ve la v2, con el
gancho en el script loader de Gecko.
- Una carga de página puede producir MÁS DE UNA petición del mismo documento, y una llega con
tabId = -1. La primera versión agrupaba por (tabId, documento) y contaba esa carga como DOS
visitas. No es cosmético: inflar las visitas estabiliza el origen antes de tiempo, y un origen
estabilizado antes de conocer su código real alerta por churn legítimo — el falso positivo que la
spec de puriy-sct pide evitar por encima de todo. Ahora se agrupa por documento, y
scripts/test-atuq-sct.py vigila el invariante: una carga, una visita.
v1 observa y avisa; no bloquea — y eso es del diseño
El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2. Acá se observa
y se avisa por dos razones: puriy-sct es explícito en que su v1 alerta y no bloquea (bloquear
necesita antes la acción del usuario, que es su M1), y poner un viaje ida-y-vuelta entre procesos en el
camino crítico de cada script de cada página no es «más seguro»: es un navegador que nadie usa.
El aviso es pasivo: una insignia en la barra con el número de scripts nuevos y, en el tooltip, sus
nombres. No un modal — un modal por cada despliegue entrena a la gente a cerrarlo sin leer, y entonces
el que importa también se cierra.
⚠ Y el límite que hay que decir primero: se hashea el TEXTO ya decodificado que la extensión
entrega, no los bytes que sirvió el servidor. La detección vale —el TOFU compara lo mismo contra lo
mismo—, pero ese hash no es comparable con el que publique un tercero sobre los bytes servidos.
scripts/test-atuq-sct.py mide la cadena entera con un servidor HTTP real y seis cargas de página, y
trae dos controles: la quinta carga repite el MISMO script ya estable y no debe alertar, y
--negative-control borra el manifiesto y exige que no haya veredicto — o sea que el veredicto de la
corrida positiva viene del host y no de la extensión inventándolo.
descargas — una descarga con identidad (§6.2)
Cuando una descarga TERMINA, la extensión le pasa al host la ruta, el nombre y de dónde salió; el host
la ingiere a un CAS BLAKE3 y contesta el hash, el tamaño y cuántas veces se bajó ese mismo
contenido. El mismo contenido con otro nombre es un objeto y dos nombres, y el navegador lo dice.
Ningún navegador trata una descarga como algo con identidad: para todos es un blob con nombre, y por
eso lo bajás dos veces y no lo sabés.
No es un almacén nuevo: es arje-cas, el mismo formato y el mismo hash que usan arje, takana y
tejido.
⚠ Tres cosas que hay que saber, y las tres están medidas:
- La raíz del CAS del navegador NO es la del sistema.
arje_cas::gc borra todo blob que no esté
en el set reachable de su llamador, y el único que existe hoy (arje-brain, GcCas) lo arma con
la cadena de audit y las raíces vivas del grafo. Una descarga no está en ninguno: el primer
GcCas se la llevaría, en silencio. Por eso van a <estado>/cas. El precio, dicho:
tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo a esa raíz.
⚠ Se llamaba descargas-cas hasta que entró el §6.3: ahora el archivo personal y las descargas
comparten un CAS, que es lo que hace que una página archivada y un fichero bajado con el mismo
contenido sean un solo objeto. El guardián de descargas se quedó mirando el nombre viejo y
estuvo rojo dos días sin que nadie lo corriera.
- El fichero que bajaste no se toca. Se copia al CAS y se deja donde estaba. El guardián lo
comprueba byte a byte.
- La extensión observa, no intercepta. Se entera cuando la descarga terminó. Meterse en el medio
obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual—
es exactamente lo que se consigue no metiéndose.
scripts/test-atuq-descargas.py baja de verdad (Content-Disposition: attachment) dos veces y exige
dedup=true la segunda; con --negative-control la segunda descarga es otro contenido y exige lo
contrario.
archivo — el archivo personal (§6.3)
Cada página que leés se congela: el HTML ya ejecutado —lo que viste, no lo que mandó el
servidor— va al CAS con su BLAKE3, y la url, el título y el texto visible al índice. Después se
busca. Volver a la misma página no la duplica; la misma URL con otro contenido es otra
página, así que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
punteros se pudren.
Lo que NO se archiva es la parte que hay que mirar:
- nada de una ventana privada — se comprueba en el fondo y no en el guión de contenido, porque
tabs.get() es lo único que sabe si la pestaña lo es; y si no se puede saber, no se archiva.
⚠ Medido: hoy el que impide archivar en privado es el navegador (las extensiones no corren
ahí salvo que se las habilite), así que nuestro incognito no se ejecuta nunca — y es justo lo
que haría seguro habilitar private_browsing el día que haga falta;
- nada fuera del marco principal: un
<iframe> de publicidad no es una página que alguien leyó;
- nada sin texto visible: el HTML de un visor de PDF llenaría el archivo de cosas que no se
encuentran.
⚠ archive.search es el registro LITERAL —todas las palabras de la consulta tienen que aparecer
en la url, el título o el texto indexado—. No es preguntarle al historial en lenguaje natural:
eso es el registro semántico, un motor rag-motor::RagMotor como willay-rag, que necesita un
daemon de embeddings y un LLM de verdad y devuelve None cuando no están. Cuando exista se enchufa
del mismo lado y esto no cambia.
scripts/test-atuq-archivo.py visita, revisita y busca; su control negativo abre la página en una
ventana privada, exige que no se archive nada y dice quién lo impidió — nuestro código o el
navegador—, porque las dos protegen pero sólo una es nuestra.
medios — el vídeo no se ve en una pestaña (§6.6)
Cuando una navegación de primer nivel resulta ser un medio —el servidor contesta video/* o
audio/*—, la navegación se cancela y la URL se la lleva mpv, que ya viaja en las cuatro imágenes
de escritorio. El navegador vuelve a ser un navegador en vez de un reproductor a medias metido en una
pestaña.
Sólo el primer nivel: un <video> embebido es parte de la página, y sacarlo de ahí rompería el
sitio que lo puso. Las reglas anchas en el camino de cada petición son las que terminan rompiendo la
web de alguien.
Si no hay reproductor, la navegación VUELVE al navegador. Cancelar es síncrono y la respuesta del
host llega después, así que no se puede saber en el momento si arrancó; se cancela sólo con el puerto
vivo y, ante un fallo, se reabre la URL con una marca para no entrar en bucle. Quedarse sin vídeo y
sin pestaña es el único resultado inaceptable, y el control negativo del guardián lo prueba.
⚠ El binario no viene del mensaje: es una constante del host. Lo único que viaja es la URL, sólo
http(s), y detrás de un --.
ia — la barra lateral le pregunta a un modelo de ESTA máquina (§6.7)
Sin nube, sin clave de API y sin un byte saliendo: el host levanta un llama-server (el llama-cpp
del corpus) contra el modelo pineado en /usr/share/takana/ia/modelo.gguf y habla con él por un
socket UNIX. No hay servicio de IA en la imagen: el primer ai.ask lo levanta, y se muere con
el navegador — al revés que el daemon del torrent (§6.9), porque un torrent tiene trabajo que
sobrevive y medio giga de modelo en RAM no.
El puerto nativo vive en el fondo, no en la página del panel: si viviera en la página, cerrar la
barra lateral se llevaría el proceso del host y el modelo cargado con ella. panel.html?q=… abre el
panel con la pregunta ya hecha, y ?p=… la manda a tus páginas en vez de al modelo.
Dos preguntas, dos botones, y no se mezclan a propósito. «Al modelo» genera texto; «A mis
páginas» ordena por parecido lo que ya leíste (§6.3, archive.ask) y muestra los títulos en
orden, sin porcentaje: el puntaje es un coseno y leerlo como «85 % de acierto» sería inventarle un
significado. Presentar las dos juntas haría imposible saber cuál contestó — una inventa y la otra
recuerda.
Cuando algo falta, el panel muestra la causa («no hay modelo en …», «no hay motor de inferencia
en …»). Una IA que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario
no puede distinguir «no hay modelo» de «el modelo dijo eso».
scripts/test-atuq-ia.py mide la cadena entera con inferencia REAL, en una jaula --unshare-net, y
comprueba además que al cerrarse el navegador queden cero llama-server. Control negativo: sin
modelo, la causa aparece y no hay respuesta inventada.
El modelo de chat es Qwen2.5-1.5B-Instruct Q4_K_M (recipes/ia-modelo-chat.toml, Apache-2.0,
1,04 GiB, 20,1 tok/s en CPU, habla español — las tres cosas medidas antes de pinearlo), y va en las
cuatro imágenes de escritorio junto con el motor: ~1,25 GiB por perfil.
El de embeddings (ia-modelo-embeddings, Qwen3-Embedding-0.6B, 610 MiB) es descarga opcional:
no está declarado en ningún perfil —decisión, no olvido— y se instala con
takana install ia-modelo-embeddings. Hasta que se instale, «A mis páginas» contesta con esa misma
línea en vez de con un «no está» sin salida.
foco — el navegador MUESTRA el foco, y no tiene con qué apagarlo (§6.5)
El foco no es un bloqueador de sitios: es una regla nft de egress por cgroup que aplicó root, y
mientras está puesta el navegador no sale a la red — con todo lo local andando (el archivo del
§6.3 se lee, el CAS del §6.2 está). Medido en una máquina con root: el proceso del cgroup en foco no
abre la conexión y el del cgroup de al lado sí (scripts/test-foco-egress.sh).
Esta extensión es la mitad del navegador, y es asimétrica a propósito: sólo lee. Pregunta
focus.state al host cada minuto —el estado lo cambia root por fuera, así que no hay evento al que
suscribirse— y pinta tres cosas y ninguna más: foco si está puesto, nada si no, y ? si nadie
escribió el estado. Ese tercer caso es el que importa: si «no sé» se redondeara a «apagado», la
insignia afirmaría que no hay foco sin haberlo mirado.
No hay verbo para apagarlo, y eso es la propiedad y no un pendiente. Si desde el navegador se
pudiera levantar, el modo foco valdría lo que vale un bloqueador de extensión: dos clics. En tawasuyu
hay un test que lo fija (focus.stop → «verbo desconocido»), y acá el guardián mide el efecto:
después de una sesión entera del navegador, el fichero de estado quedó igual.
scripts/test-atuq-foco.py corre los tres estados y comprueba, por corrida, qué dijo la extensión,
qué pintó y que el estado no cambió. Verificado rompiéndolo: con una extensión que pinta «foco» pase
lo que pase, el guardián falla nombrando la insignia.
Lo que todavía NO existe —y no se inventó de paso— es quién pone a atuq en un cgroup y quién aplica
la política: lo primero pide delegación de un subárbol a la sesión (decisión de arje, SDD 10) y lo
segundo pide root. Ver el §6.5 del SDD 26.
torrent — el magnet lo toma la suite (§6.9)
La extensión registra un handler para magnet: (protocol_handlers, que existe en nuestro build),
así que un clic abre su propia página con el enlace, y esa página se lo pasa al host. El host se
lo entrega a puriy-costura-torrent, que es otro proceso.
Por qué otro proceso y no un verbo más: una descarga tiene que sobrevivir al navegador, y el host
no lo hace —Gecko lo mata al cerrarse el puerto—. Y librqbit son 226 crates: dentro del host, ese
binario pasaría de 952 K a ~20 MB para las cinco extensiones que lo comparten.
Perezoso: no hay servicio en la imagen. El binario está y no corre hasta que hay un torrent; lo
levanta su cliente (con setsid, o se moriría con el navegador) y se va solo cuando no queda nada
activo. Sembrar cuenta como actividad.
⚠ La página no valida la forma del origen: la valida el host, que es quien decide qué le pasa al
daemon. Tener la regla escrita dos veces es tenerla mintiendo el día que una cambie.
scripts/test-atuq-torrent.py sirve un .torrent hecho a mano (un magnet: bloquearía esperando
metadata que sin peers no llega) y comprueba que el daemon aparezca sin que nadie lo arranque. Su
control negativo saca el cliente de la imagen y exige que el host lo diga en vez de fingirlo.
Por qué el CSS no es userChrome.css
userChrome.css vive en el PERFIL del usuario y exige que el usuario prenda
toolkit.legacyUserProfileCustomizations.stylesheets. Una distro no puede depender de eso: el
navegador tiene que verse como atuq la primera vez que arranca, con un perfil recién creado. La vía
que sí funciona a nivel de aplicación es el nsIStyleSheetService desde autoconfig, que es lo que
hace atuq.cfg.
La marca
El arte viene de un tablero de identidad del operador (2026-09-05): un zorro en medallón con
cenefa andina, sobre disco teal y anillo noche.
branding/icons/atuq{16,32,48,64,128,256}.png — el medallón recortado del tablero, con máscara
circular y alfa, reducido con LANCZOS desde un master de 1024 px. Se generan una vez y se copian;
no se dibujan en cada build.
- La paleta del chrome se muestreó del propio medallón, tomando el color más frecuente de cada
zona, en vez de estimarla a ojo:
#c15728 el zorro, #1b6470 el disco, #04161d el anillo,
#f5ecde los reflejos. Un token escrito «parecido» es cómo una identidad se desalinea de su logo
en tres ediciones.
- Del tablero se toman la forma y el color; los eslóganes NO — el operador los descartó
explícitamente.
brand.ftl lleva el nombre y nada más.
⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un
logo con cenefa, no un defecto del escalado: la salida sería un dibujo simplificado para ese tamaño,
y eso lo decide quien dibujó el zorro.
El chrome se programa desde atuq.cfg, no abriendo omni.ja
Esto era un pendiente («el chrome de verdad dentro de omni.ja») y resultó ser dos cosas falsas.
Medido con scripts/test-atuq-chrome.py sobre atuq b3:9e16ac35:
-
No hace falta entrar al zip para programar el chrome. atuq.cfg ya corre con privilegios —de
ahí sale nsIStyleSheetService—, así que desde ahí se observa browser-delayed-startup-finished
y se toca el gBrowser de CADA ventana. La sonda abre dos pestañas y las parte en dos, desde
atuq.cfg, sin tocar omni.ja.
Y es la vía que hay que usar, no un atajo: re-empacar omni.ja pelea con el orden del jarlog
del PGO —por eso el jarlog está aparcado—, así que cada función nueva del chrome que NO entre
al zip es una que no compite con la optimización de arranque.
-
La vista dividida ya la trae el motor, y prendida. Firefox 154 lleva
tabbrowser/tabsplitview.js (492 líneas), opentabs-splitview.mjs y split-view-footer.js
dentro de browser/omni.ja, y defaults/preferences/firefox.js dice
pref("browser.tabs.splitView.enabled", true). La sonda lo ejercita de verdad —
gBrowser.addTabSplitView([t1, t2]) ⇒ activeSplitView puesta y splitViewBrowsers.length == 2
— así que no es «existe el fichero»: funciona en nuestro build. No hay nada que escribir acá.
⚠ Lo que esto NO dice: atuq todavía no lleva JS de chrome propio. Lo medido es el GANCHO, con la
sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que lo
justifique, el fichero va en chrome/atuq.js y lo carga atuq.cfg igual que la hoja de estilo.
El control negativo de la sonda sale del propio motor: addTabSplitView documenta que si TODAS las
pestañas están fijadas borra el wrapper y devuelve null. --negative-control las fija y exige eso.
Lo que TODAVÍA no está
- La v2 de
sct: el gancho en el script loader de Gecko, que es lo único que ve los scripts
inline y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis).
- La mitad SEMÁNTICA del archivo (6.3): un motor
rag-motor::RagMotor sobre el archivo, que
necesita el daemon de embeddings y un backend LLM en la imagen. willay-rag es el molde.
- La IA local en la barra lateral (6.7), que está bloqueada por algo medido: el corpus no tiene
ninguna receta de LLM ni de embeddings. Es también la que traería el motor que le falta al archivo.
- Que una descarga se pueda COMPARTIR con
tejido: hoy el objeto está en el CAS de descargas y
tejido sirve el del sistema. Falta que el índice de descargas sea una raíz que el GC respete, y ahí
las dos raíces pueden volver a ser una.
(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.)
Instalar una extensión A MANO — y qué pasa con los datos de una «temporal»
El .xpi de las diez sale sin construir el navegador:
python3 scripts/atuq-xpi.py # ⇒ work/atuq-xpi/<id>.xpi
No es un empaquetador nuevo: ese script ejecuta empaquetar_extensiones() de tools/rebrand.py,
que es el mismo que corre en el build. Comprobado, y es la única comprobación que vale acá: los diez
.xpi generados salen byte a byte idénticos a los de los dos artefactos atuq sellados del store
(5156cb8f… y e556024b…, 10/10 iguales en los dos), y dos corridas seguidas dan los mismos bytes.
Así que el que te instalás a mano ES el de la imagen, no una variante parecida.
Lo «temporal» de about:debugging se va, y se lleva sus datos
Un complemento cargado con «Cargar complemento temporal» vive hasta que cerrás el navegador: al
reiniciar queda desinstalado, y desinstalar un complemento borra su storage.local y su entrada en
extensions.webextensions.uuids. O sea que sí, contá con que lo que configuraste ahí se pierde —
y no vuelve instalándolo después con el mismo id, porque el moz-extension:// UUID se regenera.
⚠ Esto es comportamiento documentado de Gecko, no medido en esta caja: acá no corre ningún
navegador (el firefox del corpus muere con Error relocating /lib/libstdc++.so.6: arc4random, y en el
anfitrión no hay ninguno de glibc). Si hace falta certeza, se mide en la imagen, no acá.
De las diez, la única que tiene datos de usuario que perder es proxy (medido: storage.local
sólo aparece en extensions/proxy/). Las otras nueve son sin estado o lo guardan del lado nativo
(puriy_costura), así que para ellas la pregunta es indiferente.
Y proxy tiene una capa que la temporal no se lleva: sus cuatro contenedores de fábrica vienen de
storage.managed por 3rdparty.Extensions de la política, que es de sólo lectura y no vive en el
perfil. Lo que se pierde es lo que pisaste vos desde la página de opciones.
Que quede fija
| dónde | qué hace falta |
| atuq | nada más: atuq.cfg:67 trae lockPref("xpinstall.signatures.required", false), así que un .xpi sin firmar se instala permanente desde about:addons → Instalar complemento desde archivo |
| Firefox Developer Edition / Nightly / ESR | poner xpinstall.signatures.required = false en about:config y después instalar desde archivo |
| Firefox release o beta | no se puede. El requisito de firma está clavado en esos canales y el pref no lo levanta; la salida es usar otro canal o firmar el .xpi en AMO |
Si en vez de a mano lo querés como lo hace la imagen —distribution/extensions/ + entrada en
ExtensionSettings— eso es la sección de arriba, y ahí el installation_mode: normal_installed es lo
que además le entrega su storage.managed.
⚠ Ocho de las diez no sirven de nada fuera de la imagen. boveda, archivo, ia, descargas,
torrent, sct, medios y foco piden nativeMessaging contra puriy_costura, cuyo manifiesto
apunta a /usr/lib/atuq/puriy-costura-host; sin ese binario se instalan y se quedan mudas — no
fallan al instalar, que es justo la forma que no se nota. Las dos que andan solas son proxy e
inicio.