Files
takana/recipes/atuq/README.md
T
Sergio 29831e6296 descargas: el guardián estaba ROJO hace dos días y nadie lo corría
La barrida de regresión del frente (14 guardianes tras rehacer host y navegador) encontró uno en
rojo: `test-atuq-descargas.py` buscaba el CAS en `<estado>/descargas-cas` y el host lo escribe en
`<estado>/cas` desde el commit del archivo personal (357791a85, 2026-09-10) — el §6.3 UNIFICÓ los dos
CAS, que es justo lo que hace que una página archivada y un fichero bajado con el mismo contenido
sean un solo objeto. Lo renombré yo y no actualicé este guardián.

Lo que importa no es el renombre: es que el guardián estuvo rojo dos días sin que nadie se enterara,
porque **un guardián que no se ejecuta no protege de nada** — la misma familia que el cache-hit que
congela regresiones. Los otros 13 pasan.

Arreglado el path, y el README dice ahora por qué el directorio se llama `cas` y no `descargas-cas`.
2026-09-12 10:51:10 +00:00

396 lines
27 KiB
Markdown

# 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`](../../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:
1. el XPI **tiene que quedar en `distribution/extensions/`** — es lo único que la instala;
2. 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`;
3. `extensions.autoDisableScopes = 0` en `atuq.cfg` es lo que evita que el escaneo las instale
**desactivadas**;
4. 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:
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.
## `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
1. **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.
2. **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.
3. **`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.
4. **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.
5. **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:**
1. **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.
2. **El fichero que bajaste no se toca.** Se copia al CAS y se deja donde estaba. El guardián lo
comprueba byte a byte.
3. **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:**
1. **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;
2. nada fuera del marco principal: un `<iframe>` de publicidad no es una página que alguien leyó;
3. 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 ya está elegido y pineado: **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).
Lo que sigue sin decidirse es **en qué imágenes se declara**: con el motor son ~1,25 GiB por perfil.
Mientras no se declare, la imagen no lo trae y el panel lo dice.
## `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`:
1. **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.**
2. **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.)*