> 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: una descarga deja de ser un archivo con nombre y pasa a ser un objeto con identidad
La unidad 7 del SDD 26 (§6.2). Cuando una descarga TERMINA, la extensión
`descargas@atuq.tawasuyu` 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 — que es exactamente lo
que ningún navegador sabe hacer: para todos una descarga es un blob con nombre, y por eso la bajás
dos veces y no te enterás.
No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
tejido. El `<hex>` del CAS ES el `expected_hash` de un `.swm`.
⚠ TRES DECISIONES QUE SALIERON DE MEDIR, NO DE DISEÑAR:
1. **La raíz del CAS de descargas 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 (`arje-brain`, `GcCas`) lo arma con la
cadena de audit y las raíces vivas del grafo. Una descarga del usuario no está en ninguno: el
primer GC se la llevaría, en silencio. Van a `<estado>/descargas-cas` — mismo formato (mover un
objeto al CAS del sistema es un `rename`), fuera del alcance del GC de otro. El precio queda
escrito: tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo ahí.
2. **El fichero del usuario no se toca**: se copia y se deja donde estaba. El guardián lo comprueba
byte a byte. Borrar o mover lo que alguien acaba de bajar no es decisión de un navegador.
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 lo que se consigue no metiéndose.
Y EL HALLAZGO QUE COSTÓ LA TARDE, que no es de esta unidad: **en `--headless` el navegador se cae con
SIGSEGV en cuanto una descarga termina.** Se atribuyó con dos controles antes de tocar nada:
--headless, con la extensión → baja el fichero, TERMINADA, Segmentation fault (139)
--headless, SIN la extensión → baja el fichero, Segmentation fault (139) ← no es nuestro
--headless, panel de descargas apagado → Segmentation fault (139)
sway headless (compositor REAL) → baja, INGIERE al CAS, y no se cae ← no es del producto
Sin el primer control esto se leía como «la extensión de descargas rompe el navegador» (perseguir un
bug que no existe); sin el último, como «atuq no puede descargar» (reportar un bug de producto que
tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`, y
por eso lo dice en su cabecera: un arnés distinto al de los demás guardianes, sin explicación, es una
invitación a «simplificarlo» de vuelta al que se cae. Queda en el §6.10.bis del SDD, que es donde vive
lo que atuq NO puede hacer.
El guardián baja de verdad (`Content-Disposition: attachment`, no una llamada a la API) dos veces
dentro de un mismo compositor, y trae control negativo: con OTRO contenido la segunda vez exige
`dedup=false` y dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a
una que funciona.
Del lado de tawasuyu (`ffa939c6`, pineado acá): `cas.ingest`/`cas.list` en el host y
`arje-cas::almacenar_fichero_en` — ingesta en streaming, porque `store` toma `&[u8]` y una ISO de
4 GiB serían 4 GiB de `Vec`. 5 tests nuevos en arje-cas y 5 en puriy-costura.
Y EL BUG QUE EL GUARDIÁN DESTAPÓ, que vale más que la función: **hay un host por PUERTO, no uno por
perfil.** Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en
memoria del estado, y cada uno escribía el fichero ENTERO al guardar: el de descargas borraba el
registro TOFU de `sct` al salir, y el de `sct` revertía el índice de descargas. Los dos contestaban
bien — el daño estaba sólo en el disco. Se vio porque el guardián lee el índice EN DISCO en vez de
creerle a la respuesta: decía `veces=2` y el fichero decía 1. Arreglado en tawasuyu (`55b918e8`,
pineado acá): cada proceso escribe sólo la parte que tocó, y el test que lo fija se comprobó
ROMPIENDO el arreglo a propósito — porque la primera versión de ese test abría los hosts en secuencia
y pasaba con el bug puesto.
diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index fc5fbcb2..ff64d2b9 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -557,6 +557,69 @@ dos veces y no lo sabés.
Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en
una carpeta.
+#### 6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida
+
+`cas.ingest` en el host + la extensión `descargas@atuq.tawasuyu`, que observa las descargas
+TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el
+tamaño, con qué nombre apareció la primera vez y **cuántas veces se bajó ese mismo contenido**.
+
+ 1ª descarga contenido A como `uno.bin` ⇒ dedup=false, veces=1
+ 2ª descarga contenido A como `dos.bin` ⇒ dedup=TRUE, veces=2, UN solo blob en disco
+
+**No es un almacén nuevo**: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
+`tejido` — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por
+acá queda donde los otros lo van a buscar, y el `<hex>` del CAS **es** el `expected_hash` de un `.swm`.
+
+**Tres cosas que este trabajo dejó, y ninguna es la feature:**
+
+1. **`arje-cas` gana ingesta en streaming.** `store` toma `&[u8]`, así que ingerir un fichero obliga a
+ leerlo entero: para los binarios de arje da igual, para lo que baja un navegador no —una ISO de
+ 4 GiB serían 4 GiB de `Vec`—. `almacenar_fichero_en` hace UNA pasada (lee, hashea y escribe a la
+ vez, a un temporal con el PID, y recién entonces renombra al `<hex>`) y devuelve
+ `(hash, bytes, ya_estaba)`: ese `ya_estaba` **es** la deduplicación vista desde afuera.
+2. ⚠ **La raíz del CAS de descargas no es la del sistema, y es por una pérdida de datos medida.**
+ `arje_cas::gc` borra todo blob que no esté en el set `reachable` de su llamador, y el único
+ llamador que existe hoy —`arje-brain::introspect`, `IntrospectRequest::GcCas`— lo arma con la
+ cadena de audit + las raíces vivas del grafo + lo que le pase el caller. **Una descarga del usuario
+ no está en ninguno de esos conjuntos: el primer `GcCas` se la llevaría, en silencio.** Por eso van
+ a `<estado>/descargas-cas`: mismo formato (mover un objeto al CAS del sistema es un `rename`),
+ fuera del alcance del GC de otro. Lo que se pierde, dicho: `tejido` sirve el CAS por defecto, así
+ que compartir una descarga hoy exige apuntarlo a esta raíz (`ENTE_CAS_ROOT`, la perilla que su
+ propio código documenta) hasta que el índice de descargas sea una raíz que el GC respete.
+3. **No se toca el fichero del usuario.** Se copia al CAS y se deja donde estaba. Borrar o mover lo
+ que alguien acaba de bajar —aunque «ya esté en el CAS»— no es una decisión que le toque a un
+ navegador tomar por su cuenta, y el guardián lo comprueba byte a byte.
+
+El índice va en **JSON** y no en postcard a propósito: es la lista de lo que el usuario bajó y tiene
+que poder leerse con `cat` el día que el binario no arranque. El estado de `sct` es interno; esto es
+suyo.
+
+**Y ahí apareció el bug que valía más que la función: hay UN HOST POR PUERTO, no uno por perfil.**
+Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en memoria
+del estado, y cada uno escribía el fichero ENTERO al guardar. El de descargas **borraba el registro
+TOFU de `sct`** al salir; el de `sct` **revertía el índice de descargas** al valor que tenía cuando
+arrancó. Los dos contestaban bien: el daño estaba sólo en el disco.
+
+Se encontró porque el guardián lee el índice **en disco** en vez de creerle a la respuesta del host —
+la respuesta decía `veces=2` y el fichero decía 1. Aseverar sobre lo que el proceso contestó habría
+dejado pasar una pérdida de datos silenciosa. El arreglo es que cada proceso escriba **sólo la parte
+que tocó**, y lo que eso NO cubre queda dicho: dos procesos que toquen la MISMA parte se seguirían
+pisando, y ese día hace falta un dueño único del estado, no más banderas.
+
+⚠ **Y el test de ese arreglo también costó, con una lección propia:** la primera versión abría los dos
+hosts en SECUENCIA y **pasaba con el bug puesto**, porque el segundo cargaba de disco lo que el
+primero acababa de escribir. El bug necesita que las vidas se SOLAPEN. Se comprobó rompiendo el
+arreglo a propósito —con la rotura el test falla nombrando la causa, sin ella pasa—, que es la única
+forma de saber si un guardián sirve.
+
+`scripts/test-atuq-descargas.py` baja de verdad —`Content-Disposition: attachment`, no una llamada a
+la API— dos veces, y trae control negativo: con OTRO contenido la segunda vez, exige `dedup=false` y
+dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
+
+⚠ Y corre sobre **sway headless**, no sobre `--headless`, porque en `--headless` una descarga
+terminada mata al navegador con SIGSEGV — con y sin nuestra extensión. Está medido y atribuido en
+el §6.10.bis.
+
### 6.3 El archivo personal es la razón por la que alguien usaría `atuq`
Cada página visitada se congela en el CAS y entra al DAG de `khipu`; después le preguntás a tu
@@ -778,6 +841,30 @@ detalle del build.
sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
+#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
+
+Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
+
+| corrida | qué pasa |
+|---|---|
+| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
+| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
+| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
+| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
+
+Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
+y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
+descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
+
+Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
+navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto
+que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`,
+y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación,
+es una invitación a «simplificarlo» de vuelta al que se cae.
+
+⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
+falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
+
### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
@@ -1167,7 +1254,7 @@ siguiente.
| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
| 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
-| 7 | Descargas al CAS | 6.2, y alimenta 6.9 | 5 |
+| 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ |
| 8 | Archivo + RAG | 6.3 | 5, 7 |
| 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 | — |
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index da2e10af..a2887124 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -25,6 +25,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y
| `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). |
| `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. |
@@ -160,6 +161,34 @@ trae dos controles: la quinta carga repite el MISMO script ya estable y **no** d
`--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 de descargas 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>/descargas-cas`. El precio, dicho:
+ `tejido` sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo a esa raíz.
+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.
+
## Por qué el CSS no es `userChrome.css`
`userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
@@ -219,8 +248,11 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control
- **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).
-- **Los otros verbos del §6** sobre el host que ya está puesto: descargas al CAS (6.2), archivo con
- RAG (6.3), torrent (6.9), IA local en la barra lateral (6.7).
+- **Los otros verbos del §6** sobre el host que ya está puesto: archivo con RAG (6.3), torrent (6.9),
+ IA local en la barra lateral (6.7).
+- **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.)*
diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json
index f1139f25..2c5b7129 100644
--- a/recipes/atuq/distribution/policies.json
+++ b/recipes/atuq/distribution/policies.json
@@ -39,6 +39,10 @@
"sct@atuq.tawasuyu": {
"installation_mode": "normal_installed",
"install_url": "file:///usr/lib/atuq/distribution/extensions/sct@atuq.tawasuyu.xpi"
+ },
+ "descargas@atuq.tawasuyu": {
+ "installation_mode": "normal_installed",
+ "install_url": "file:///usr/lib/atuq/distribution/extensions/descargas@atuq.tawasuyu.xpi"
}
}
}
diff --git a/recipes/atuq/extensions/descargas/fondo.js b/recipes/atuq/extensions/descargas/fondo.js
new file mode 100644
index 00000000..7d9d9aeb
--- /dev/null
+++ b/recipes/atuq/extensions/descargas/fondo.js
@@ -0,0 +1,136 @@
+"use strict";
+// descargas — una descarga con identidad (SDD 26 §6.2), v1.
+//
+// Cuando una descarga TERMINA, esta extensión le pasa al host nativo la ruta del
+// fichero, su nombre y de dónde salió. El host lo ingiere a un CAS BLAKE3 —el de
+// `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y tejido— y
+// contesta el hash, el tamaño, con qué nombre apareció la PRIMERA vez y cuántas
+// veces se bajó ese mismo contenido.
+//
+// Para qué: 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. Acá el
+// mismo contenido con otro nombre es UN objeto y dos nombres, y se dice.
+//
+// ── LO QUE NO HACE, Y ES DELIBERADO ──────────────────────────────────────────
+// No toca el fichero que bajaste. El host lo COPIA al CAS y lo deja donde estaba.
+// Mover o borrar lo que alguien acaba de bajar —aunque «ya esté en el CAS»— no es
+// una decisión que le toque a un navegador tomar por su cuenta.
+//
+// Y no intercepta la descarga: la observa cuando terminó. Meterse en el medio
+// obligaría a decidir qué hacer cuando el CAS falla, y la respuesta correcta a eso
+// («que la descarga siga igual») es exactamente lo que se consigue no metiéndose.
+
+const HOST = "puriy_costura";
+
+let puerto = null;
+// La descarga que motivó cada mensaje, para poder pintar la insignia en su
+// respuesta: el host devuelve el `id` que le mandamos, y el `id` de una descarga
+// es justo un entero.
+const pendientes = new Map();
+
+function di(s) {
+ try { dump("DESCARGAS " + s + "\n"); } catch (e) {}
+}
+
+function conectar() {
+ try {
+ puerto = browser.runtime.connectNative(HOST);
+ puerto.onMessage.addListener(alRecibir);
+ puerto.onDisconnect.addListener(p => {
+ di("DESCONECTADO " + (p.error ? p.error.message : "sin error"));
+ puerto = null;
+ });
+ di("CONECTADO " + HOST);
+ } catch (e) {
+ di("SIN HOST " + e.message);
+ puerto = null;
+ }
+}
+
+function enviar(msg) {
+ if (!puerto) { conectar(); }
+ if (!puerto) { return false; }
+ try {
+ puerto.postMessage(msg);
+ return true;
+ } catch (e) {
+ di("ENVÍO FALLÓ " + e.message);
+ puerto = null;
+ return false;
+ }
+}
+
+function alRecibir(r) {
+ if (!r || r.ok !== true) {
+ di("RECHAZO " + (r && r.error ? r.error : "sin error"));
+ return;
+ }
+ if (r.verb !== "cas.ingest" || !r.object) { return; }
+ const o = r.object;
+ di("INGERIDO " + o.hash + " nombre=" + o.name + " bytes=" + o.bytes +
+ " veces=" + o.times + " dedup=" + (r.deduped === true));
+ pendientes.delete(r.id);
+ pintar(o, r.deduped === true);
+}
+
+// El aviso es la insignia del botón, no un modal: es información, no una alarma.
+// Sólo marca cuando hay algo que decir —que ese contenido ya lo tenías—, porque
+// una insignia que está siempre encendida deja de leerse.
+function pintar(o, dedup) {
+ try {
+ if (dedup) {
+ browser.browserAction.setBadgeText({ text: String(o.times) });
+ browser.browserAction.setBadgeBackgroundColor({ color: "#1b6470" });
+ browser.browserAction.setTitle({
+ title: "Esto ya lo tenías: «" + o.name + "»\n" +
+ "es la vez " + o.times + " que bajás el mismo contenido\n" +
+ "blake3:" + o.hash.slice(0, 16) + "…",
+ });
+ } else {
+ browser.browserAction.setBadgeText({ text: "" });
+ browser.browserAction.setTitle({
+ title: "«" + o.name + "» — " + o.bytes + " bytes\nblake3:" +
+ o.hash.slice(0, 16) + "…",
+ });
+ }
+ browser.browserAction.getBadgeText({}).then(
+ t => di("INSIGNIA texto=" + JSON.stringify(t)),
+ e => di("INSIGNIA NO SE PUDO LEER " + e.message));
+ } catch (e) { di("INSIGNIA FALLÓ " + e.message); }
+}
+
+async function terminada(id) {
+ let item;
+ try {
+ const r = await browser.downloads.search({ id });
+ item = r && r[0];
+ } catch (e) {
+ di("BUSCAR FALLÓ " + e.message);
+ return;
+ }
+ if (!item || !item.filename) {
+ di("SIN RUTA para la descarga " + id);
+ return;
+ }
+ // `filename` de la API de descargas es la ruta ABSOLUTA en disco, no el nombre
+ // (el nombre visible es el último segmento). El host además la exige absoluta.
+ const nombre = item.filename.split("/").pop();
+ di("TERMINADA " + id + " " + item.filename + " (" + item.fileSize + " bytes)");
+ pendientes.set(id, nombre);
+ enviar({
+ id,
+ verb: "cas.ingest",
+ path: item.filename,
+ name: nombre,
+ origin: item.url || "",
+ });
+}
+
+browser.downloads.onChanged.addListener(delta => {
+ if (delta.state && delta.state.current === "complete") {
+ terminada(delta.id);
+ }
+});
+
+conectar();
+di("LISTA v1 observa descargas terminadas; no toca el fichero");
diff --git a/recipes/atuq/extensions/descargas/manifest.json b/recipes/atuq/extensions/descargas/manifest.json
new file mode 100644
index 00000000..eaa85084
--- /dev/null
+++ b/recipes/atuq/extensions/descargas/manifest.json
@@ -0,0 +1,26 @@
+{
+ "manifest_version": 2,
+ "name": "atuq — descargas con identidad",
+ "description": "Lo que bajás no es un archivo con nombre: es un objeto BLAKE3 en el CAS, deduplicado y verificable (SDD 26 §6.2).",
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "descargas@atuq.tawasuyu",
+ "strict_min_version": "115.0"
+ }
+ },
+ "permissions": [
+ "downloads",
+ "nativeMessaging"
+ ],
+ "background": {
+ "scripts": ["fondo.js"]
+ },
+ "browser_action": {
+ "default_title": "Descargas con identidad",
+ "default_icon": "atuq128.png"
+ },
+ "icons": {
+ "128": "atuq128.png"
+ }
+}
diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json
index ea2081ff..1b296fec 100644
--- a/recipes/atuq/native-messaging/puriy_costura.json
+++ b/recipes/atuq/native-messaging/puriy_costura.json
@@ -3,5 +3,8 @@
"description": "La costura: el host nativo de atuq (SDD 26 §7). Le habla a puriy-sct.",
"path": "/usr/lib/atuq/puriy-costura-host",
"type": "stdio",
- "allowed_extensions": ["sct@atuq.tawasuyu"]
+ "allowed_extensions": [
+ "sct@atuq.tawasuyu",
+ "descargas@atuq.tawasuyu"
+ ]
}
diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml
index 6eefb5b6..00f54123 100644
--- a/recipes/puriy-costura.toml
+++ b/recipes/puriy-costura.toml
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
[source]
repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
-commit = "ebe41e9683f8d53c988061dd8e46eb74c2057490"
+commit = "55b918e89970650a9160fb7d532ef4f9b0648400"
# ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10.
# tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip
# async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`.
diff --git a/scripts/test-atuq-descargas.py b/scripts/test-atuq-descargas.py
new file mode 100755
index 00000000..edab148f
--- /dev/null
+++ b/scripts/test-atuq-descargas.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+"""¿Lo que baja `atuq` tiene identidad, y sabe que ya lo tenías? (SDD 26 §6.2)
+
+ python3 scripts/test-atuq-descargas.py # dos descargas del MISMO contenido
+ python3 scripts/test-atuq-descargas.py --negative-control # la segunda es OTRO contenido
+
+── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
+Un servidor HTTP sirve un fichero como adjunto; el navegador lo baja de verdad (no es una llamada a
+la API: es la descarga completa, con su `Content-Disposition`); la extensión `descargas@atuq` ve que
+terminó, le pasa al host la ruta absoluta, y el host lo ingiere a un CAS BLAKE3 y contesta el hash,
+el tamaño y **cuántas veces se bajó ese mismo contenido**.
+
+ servidor HTTP → descarga real → downloads.onChanged → connectNative → puriy-costura
+ → arje-cas (BLAKE3, deduplicado)
+
+── LAS TRES AFIRMACIONES ─────────────────────────────────────────────────────────────────────
+ 1ª descarga contenido A, nombre `uno.bin` ⇒ dedup=false, veces=1
+ 2ª descarga contenido A, nombre `dos.bin` ⇒ **dedup=true, veces=2** y UN solo blob en disco
+ el fichero que bajó el usuario sigue donde estaba, byte a byte
+
+La segunda es la del §6.2: «lo bajás dos veces y no lo sabés». El nombre cambia y el objeto es el
+mismo, porque la identidad es el contenido.
+
+`--negative-control` baja OTRO contenido la segunda vez y exige `dedup=false` y DOS objetos. Sin él,
+una sonda rota que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
+
+── ⚠ POR QUÉ ESTO CORRE EN UN SWAY DE VERDAD Y NO EN `--headless` ────────────────────────────
+Porque en `--headless` **el navegador se cae con SIGSEGV en cuanto una descarga termina**, y eso se
+midió acá antes de escribir una línea de este guardián:
+
+ con la extensión de descargas → baja el fichero, TERMINADA, Segmentation fault (139)
+ SIN la extensión (control) → baja el fichero, Segmentation fault (139)
+ con el panel de descargas apagado→ Segmentation fault (139)
+
+O sea que **no es nuestro**: es el modo headless de Gecko. Y no es del producto tampoco: con un
+compositor real —sway headless, salida offscreen— la misma descarga completa la cadena entera sin
+caerse. La conclusión importa más que el arnés: si esto se hubiera dado por «la función no anda», se
+habría perseguido un bug que no existe; y si se hubiera dado por «el navegador no puede descargar»,
+se habría reportado un bug de producto que tampoco existe. Queda anotado en el §6.10 del SDD, que es
+donde vive lo que atuq NO puede hacer.
+
+REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`), y `atuq` +
+`puriy-costura` sellados.
+"""
+import http.server
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
+ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
+HAMMER = os.path.join(ROOT, "target/release/takana")
+STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
+
+CONTENIDO_A = b"un objeto con identidad, no un archivo con nombre\n" * 64
+CONTENIDO_B = b"otro contenido cualquiera, para el control negativo\n" * 64
+
+
+def fatal(msg):
+ print(f"✗ {msg}", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def artefacto(receta, nombre, escotilla=None):
+ """El artefacto VIGENTE, por `takana hash` y nunca por glob. La escotilla grita."""
+ forzado = os.environ.get(escotilla) if escotilla else None
+ if forzado:
+ if not os.path.isdir(forzado):
+ fatal(f"{escotilla}={forzado} no existe")
+ print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
+ return forzado
+ r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
+ cwd=ROOT, capture_output=True, text=True)
+ if r.returncode != 0:
+ fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
+ h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
+ d = os.path.join(STORE, f"{h}-{nombre}")
+ if not os.path.isdir(d):
+ fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
+ return d
+
+
+class Servidor(http.server.SimpleHTTPRequestHandler):
+ """Sirve cada ruta como ADJUNTO. El `Content-Disposition` es lo que hace que el navegador lo
+ baje en vez de pintarlo: sin él esto mediría otra cosa."""
+
+ contenidos = {}
+
+ def log_message(self, *a):
+ pass
+
+ def do_GET(self):
+ nombre = self.path.lstrip("/").split("?")[0]
+ datos = Servidor.contenidos.get(nombre)
+ if datos is None:
+ self.send_error(404)
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "application/octet-stream")
+ self.send_header("Content-Disposition", f'attachment; filename="{nombre}"')
+ self.send_header("Content-Length", str(len(datos)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(datos)
+
+
+def puerto_libre():
+ s = socket.socket()
+ s.bind(("127.0.0.1", 0))
+ p = s.getsockname()[1]
+ s.close()
+ return p
+
+
+def correr(atuq, costura, salida, urls):
+ """Levanta UN sway headless y baja, dentro de él, las URLs en orden (un atuq por descarga).
+
+ Un solo compositor para las dos descargas: levantarlo dos veces costaría el doble y no mediría
+ nada más. El perfil y el `$HOME` se reusan, así que la segunda descarga encuentra el estado que
+ dejó la primera — que es justo lo que hace falta para que la deduplicación signifique algo.
+ """
+ bajadas = " ".join(f"'{u}'" for u in urls)
+ dentro = f"""#!/bin/sh
+export XDG_RUNTIME_DIR=/run/user/0 HOME=/salida/hogar LANG=C XDG_DATA_DIRS=/usr/share
+export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
+mkdir -p "$XDG_RUNTIME_DIR" /salida/hogar /salida/bajadas; chmod 700 "$XDG_RUNTIME_DIR"
+(
+ i=0
+ while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
+ [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland"; exit 1; }}
+ export WAYLAND_DISPLAY=wayland-1
+ /usr/bin/swaybg -c '#1a4b8c' & sleep 3
+ export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland LIBGL_DRIVERS_PATH=/usr/lib/dri
+ # Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
+ # con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
+ export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
+ for u in {bajadas}; do
+ # La marca va AL MISMO log que lee el guardián: si sale por la consola, el
+ # parser no tiene con qué separar una descarga de la otra.
+ echo "@@ bajando $u" >> /salida/atuq.log
+ timeout 60 /usr/bin/atuq --no-remote --profile /salida/perfil "$u" >> /salida/atuq.log 2>&1
+ echo "@@ atuq salió con $?" >> /salida/atuq.log
+ done
+ sleep 2
+ # El socket de sway se busca explícitamente: esta subshell arrancó ANTES que sway
+ # (es hija del shell que después lo `exec`a), así que no hereda `SWAYSOCK` y sin él
+ # `swaymsg exit` no hace nada — la jaula se quedaría viva hasta el timeout externo.
+ SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
+ export SWAYSOCK
+ /usr/bin/swaymsg exit 2>/dev/null
+) &
+exec /usr/bin/sway -d > /salida/sway.log 2>&1
+"""
+ guion = os.path.join(salida, "dentro.sh")
+ with open(guion, "w") as fh:
+ fh.write(dentro)
+ orden = [
+ "bwrap",
+ "--overlay-src", ALPINE, "--overlay-src", RFS,
+ "--overlay-src", atuq, "--overlay-src", costura,
+ "--tmp-overlay", "/",
+ "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
+ "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
+ "--bind", salida, "/salida",
+ "--uid", "0", "--gid", "0",
+ "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
+ "/bin/sh", "/salida/dentro.sh",
+ ]
+ try:
+ subprocess.run(orden, check=False, timeout=300)
+ except subprocess.TimeoutExpired:
+ # El compositor no se cerró solo. No es motivo para perder la corrida: las
+ # descargas y el log ya están en disco, y las aserciones son sobre eso.
+ print(" ⚠ el compositor no se cerró solo; se lo cortó a los 300 s")
+ ruta = os.path.join(salida, "atuq.log")
+ log = open(ruta, errors="replace").read() if os.path.exists(ruta) else ""
+ # Las líneas de cada descarga, separadas por el `@@ bajando` que las precede.
+ tandas, actual = [], None
+ for l in log.splitlines():
+ if l.startswith("@@ bajando"):
+ actual = []
+ tandas.append(actual)
+ elif l.startswith("DESCARGAS ") and actual is not None:
+ actual.append(l[len("DESCARGAS "):])
+ return tandas, log
+
+
+def main():
+ negativo = "--negative-control" in sys.argv[1:]
+ if negativo:
+ print("== MODO CONTROL NEGATIVO: la segunda descarga es OTRO contenido; NO debe deduplicar")
+ if not os.path.isdir(RFS):
+ fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
+ atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
+ costura = artefacto("puriy-costura", "puriy-costura")
+ print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
+
+ segundo = CONTENIDO_B if negativo else CONTENIDO_A
+ Servidor.contenidos = {"uno.bin": CONTENIDO_A, "dos.bin": segundo}
+ puerto = puerto_libre()
+ servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
+ threading.Thread(target=servidor.serve_forever, daemon=True).start()
+
+ tmp = tempfile.mkdtemp(prefix="atuq-desc-")
+ salida = os.path.join(tmp, "salida")
+ perfil = os.path.join(salida, "perfil")
+ os.makedirs(perfil)
+ try:
+ with open(os.path.join(perfil, "user.js"), "w") as fh:
+ fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
+ fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
+ # Bajar SIN preguntar y a un directorio conocido. Sin esto el navegador
+ # headless se queda esperando un diálogo que nadie va a contestar, y la
+ # prueba mediría un timeout en vez de una descarga.
+ fh.write('user_pref("browser.download.folderList", 2);\n')
+ fh.write('user_pref("browser.download.dir", "/salida/bajadas");\n')
+ fh.write('user_pref("browser.download.useDownloadDir", true);\n')
+ fh.write('user_pref("browser.download.always_ask_before_handling_new_types", false);\n')
+ fh.write('user_pref("browser.helperApps.neverAsk.saveToDisk", "application/octet-stream");\n')
+ fh.write('user_pref("browser.download.start_downloads_in_tmp_dir", false);\n')
+
+ urls = [f"http://127.0.0.1:{puerto}/uno.bin", f"http://127.0.0.1:{puerto}/dos.bin"]
+ bajadas, log = correr(atuq, costura, salida, urls)
+ for i, lineas in enumerate(bajadas):
+ print(f"\n-- descarga {('uno.bin', 'dos.bin')[i] if i < 2 else i}")
+ for l in lineas:
+ print(" " + l)
+ if len(bajadas) != 2 or not all(any(l.startswith("INGERIDO ") for l in t) for t in bajadas):
+ destino = os.path.join(ROOT, "work", "atuq-descargas.log")
+ os.makedirs(os.path.dirname(destino), exist_ok=True)
+ open(destino, "w").write(log)
+ fatal(f"alguna descarga no llegó al CAS — log en {destino}")
+
+ def ingerido(lineas):
+ for l in lineas:
+ if l.startswith("INGERIDO "):
+ partes = l.split()
+ campos = dict(p.split("=", 1) for p in partes if "=" in p)
+ campos["hash"] = partes[1]
+ return campos
+ return None
+
+ uno, dos = ingerido(bajadas[0]), ingerido(bajadas[1])
+ cas = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas-cas")
+ indice = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas.json")
+
+ if uno["dedup"] != "false" or uno["veces"] != "1":
+ fatal(f"la PRIMERA descarga no debía estar en el CAS: {uno}")
+
+ # El fichero del usuario sigue donde estaba, y con sus bytes. Es la promesa
+ # explícita del §6.2: el CAS es una COPIA, no una mudanza.
+ for nombre, contenido in (("uno.bin", CONTENIDO_A), ("dos.bin", segundo)):
+ f = os.path.join(salida, "bajadas", nombre)
+ if not os.path.isfile(f):
+ fatal(f"el fichero bajado {nombre} ya no está donde el usuario lo dejó")
+ if open(f, "rb").read() != contenido:
+ fatal(f"el fichero bajado {nombre} no es byte a byte lo que se sirvió")
+
+ blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
+ objetos = json.load(open(indice)) if os.path.isfile(indice) else {}
+
+ if negativo:
+ if dos["dedup"] != "false":
+ fatal(f"con OTRO contenido dijo dedup={dos['dedup']} — esta prueba no distingue nada")
+ if dos["hash"] == uno["hash"]:
+ fatal("dos contenidos distintos dieron el MISMO hash")
+ if len(blobs) != 2 or len(objetos) != 2:
+ fatal(f"se esperaban DOS objetos y hay blobs={blobs} índice={list(objetos)}")
+ print("\n✓ control negativo: otro contenido es otro objeto, y no dice que ya lo tenías")
+ return 0
+
+ if dos["dedup"] != "true":
+ fatal(f"la SEGUNDA descarga del mismo contenido dijo dedup={dos['dedup']}"
+ " — que es justo lo que ningún navegador sabe hacer")
+ if dos["hash"] != uno["hash"]:
+ fatal(f"el mismo contenido dio dos hashes: {uno['hash']} y {dos['hash']}")
+ if dos["veces"] != "2":
+ fatal(f"no contó las dos veces: {dos}")
+ if dos["nombre"] != "uno.bin":
+ fatal(f"el nombre tenía que ser el de la PRIMERA vez y es {dos['nombre']}")
+ if blobs != [uno["hash"]]:
+ fatal(f"el CAS tiene {blobs} y tenía que tener UN blob llamado {uno['hash']}")
+ if list(objetos) != [uno["hash"]] or objetos[uno["hash"]]["times"] != 2:
+ fatal(f"el índice no cuenta un objeto bajado dos veces: {objetos}")
+
+ # Y el blob es el contenido servido, byte a byte, bajo un nombre que es su hash.
+ if open(os.path.join(cas, uno["hash"]), "rb").read() != CONTENIDO_A:
+ fatal("el blob del CAS no es el contenido que sirvió el servidor")
+
+ print(f"\nCAS: {blobs[0][:16]}… ({len(CONTENIDO_A)} bytes) · índice: {list(objetos)[0][:16]}…"
+ f" veces={objetos[uno['hash']]['times']}")
+ print("\n✓ la descarga entra al CAS con su BLAKE3, y el fichero del usuario no se toca")
+ print("✓ el MISMO contenido con otro nombre es UN objeto, y el navegador lo dice"
+ " («esto ya lo tenías»)")
+ print("✓ el índice queda legible en disco: la lista de lo que bajaste sobrevive al binario")
+ return 0
+ finally:
+ servidor.shutdown()
+ shutil.rmtree(tmp, ignore_errors=True)
+
+
+sys.exit(main())