From c214438f008d9ad90a0d739ca2dbaa69e707d20c Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 10 Sep 2026 20:45:53 +0000 Subject: [PATCH] atuq: el torrent lo toma un daemon propio, perezoso, que sobrevive al navegador MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El 6.9 del SDD 26, con la arquitectura que pidió el operador: daemon PROPIO, CONFIGURABLE y PEREZOSO. Vivaldi ya trae torrent y termina en una carpeta; acá lo que baja entra al CAS —un objeto BLAKE3 con la misma identidad que una descarga del navegador (§6.2) o un `.swm`—, y ésa es la razón por la que esto vale. página de la extensión → fondo → connectNative → puriy-costura → /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está) → daemon: librqbit, y al completarse, el CAS POR QUÉ NO ES UN VERBO MÁS DEL HOST — dos razones, y la primera decide: 1. una descarga tiene que sobrevivir al navegador, y Gecko mata al host cuando se cierra el puerto (medido hoy); 2. librqbit + tokio + rustls son 226 crates: dentro del host, ese binario —952 K, compartido por CINCO extensiones— pasaría a ~20 MB y cada iteración de cualquier función del navegador a un cuarto de hora. ⚠ Que esa pila COMPILA para musl con zig-cc se midió ANTES de decidir, con una receta desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus había construido tokio+rustls desde tawasuyu— así que la decisión no fue «no se puede», fue «no ahí». El artefacto del daemon pesa 7,7 M. PEREZOSO EN LOS DOS SENTIDOS: no hay servicio en la imagen (el binario está y no corre hasta que hay un torrent; lo levanta su cliente con `setsid`, sin lo cual moriría con el navegador) y se va solo tras `inactividad_seg` sin nada activo — sembrar cuenta como actividad. CONFIGURABLE: TOML opcional; sin fichero anda, y uno ROTO es error. El CAS por defecto es el del host, con un test que falla si esas raíces se separan. LO QUE EL GUARDIÁN NO HACE, Y POR QUÉ (está en su cabecera y en el §6.9): · no usa un `magnet:` sino un `.torrent` servido por HTTP —dar de alta un magnet BLOQUEA esperando metadata que sin peers no llega: se mediría un timeout y no la cadena—. El `.torrent` se arma en el propio guardián, en bencode a mano, porque un binario de prueba en el repo es una dependencia que nadie revisa; · no pasa por el despacho de protocolos de Gecko: un clic en `magnet:` abre el diálogo de «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no código nuestro. Se abre la página de la extensión —la misma que el handler abriría— descubriendo su URL base del `dump` de una primera corrida, porque el UUID lo asigna Gecko por perfil y adivinarlo sería inventar. El control negativo saca el cliente del daemon de la imagen y exige que el host lo diga NOMBRANDO lo que falta, en vez de fingir que lo tomó. ⚠ Y sigue sin haber test automático de un transfer REAL entre peers: haría falta un sembrador y una espera que volverían la suite una que nadie corre. Dicho en el test del daemon y en el §6.9, para que nadie lo lea como «probado de punta a punta». La página de la extensión NO valida la forma del origen: la valida el host, que es quien decide qué le pasa al daemon. Tener esa regla escrita dos veces es tenerla mintiendo el día que una cambie. Del §6 quedan el foco (6.5) y la IA local (6.7), ésta bloqueada por algo medido: el corpus no tiene ninguna receta de LLM ni de embeddings. MEDIDO sobre `atuq b3:d1a444ad`, `puriy-costura b3:cad5c855` y `puriy-costura-torrent b3:76afb7a8` (7,8 M): MAGNET http://…/prueba.torrent TOMADO prueba-atuq.bin · nuevo · en /salida/hogar/Downloads · id=0 socket del daemon: run/puriy-costura-torrent.sock ← nadie lo arrancó ⚠ Y DOS COSAS QUE EL GUARDIÁN DESTAPÓ, las dos por comprobar el NOMBRE y no sólo que la respuesta llegara: 1. **la extensión leía claves que el daemon no manda** (`name`/`files`/`bytes` contra `nombre`/`carpeta`), y el síntoma era «TOMADO — 0 fichero(s), 0 bytes»: parecía un torrent vacío y era un vocabulario inventado del lado del lector — la misma trampa que había evitado en el host y no acá; 2. mirándolo apareció que **el socket y la config del daemon estaban en castellano**, y la regla 7 de tawasuyu nombra explícitamente las claves de configuración entre lo que se tipea. Corregido allá antes de que llegara a ninguna imagen: un protocolo y un fichero de config son contrato, y renombrarlos después rompe lo que alguien ya escribió. tawasuyu: 4f36f057 (el crate) · 3f69aab2 (lock) · 3b56db26 (el verbo del host) · 066b3761 (el log del daemon, que faltaba y hacía invisible su único fallo de arranque) · ca9947b9 (las claves en inglés). --- docs/26-atuq-envoltorio-gecko.md | 64 ++++- recipes/atuq.toml | 5 +- recipes/atuq/README.md | 27 +- recipes/atuq/distribution/policies.json | 4 + recipes/atuq/extensions/torrent/fondo.js | 109 ++++++++ recipes/atuq/extensions/torrent/manifest.json | 33 +++ recipes/atuq/extensions/torrent/tomado.html | 18 ++ recipes/atuq/extensions/torrent/tomado.js | 33 +++ .../atuq/native-messaging/puriy_costura.json | 3 +- recipes/puriy-costura-torrent.toml | 45 ++++ recipes/puriy-costura.toml | 2 +- scripts/test-atuq-torrent.py | 255 ++++++++++++++++++ 12 files changed, 592 insertions(+), 6 deletions(-) create mode 100644 recipes/atuq/extensions/torrent/fondo.js create mode 100644 recipes/atuq/extensions/torrent/manifest.json create mode 100644 recipes/atuq/extensions/torrent/tomado.html create mode 100644 recipes/atuq/extensions/torrent/tomado.js create mode 100644 recipes/puriy-costura-torrent.toml create mode 100755 scripts/test-atuq-torrent.py diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index 771efa81..95275c6c 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -834,6 +834,68 @@ directas, nadie llamando al proxy—. La única diferencia entre las dos corrida configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto ciega se vería idéntica a una que funciona. +### 6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso + +Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que **lo que baja entra al +CAS**: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del +navegador (§6.2) o un `.swm` de takana. Ésa, y no «tener torrent», es la razón por la que esto vale. + + página de la extensión → fondo → connectNative → puriy-costura + → /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está) + → daemon: librqbit, y al completarse, el CAS + +**Por qué un proceso aparte y no un verbo más del host — dos razones, y la primera decide:** + +1. **Una descarga tiene que sobrevivir al navegador.** Gecko lanza un host de native messaging **por + puerto** y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente + BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la pestaña. +2. **El precio.** `librqbit` + tokio + rustls son **226 crates**: ese host —952 K, compartido por las + cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un + cuarto de hora. + +⚠ **Y que esa pila COMPILA para musl con zig-cc se midió antes de decidir**, con una receta +desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus había construido +tokio+rustls desde tawasuyu; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la +decisión no fue «no se puede», fue **«no ahí»**. Medir primero es lo que separa una decisión de +arquitectura de una excusa. + +**Perezoso en los dos sentidos**, que es lo que pidió el operador: + +| | qué significa | +|---|---| +| nadie lo arranca | **no hay servicio en la imagen**: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar | +| se va solo | cuando no queda nada activo por `inactividad_seg` (300 s por defecto; `0` = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre | + +⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con +él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca +igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una +degradación silenciosa de su única promesa. + +**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`, +`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un +error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en +cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos +raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**. + +**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`): + +1. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA + esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la + cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma + en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que + nadie revisa; +2. **no pasa por el despacho de protocolos de Gecko** — un clic en un `magnet:` abre el diálogo de + «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no + código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo + su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil). + +Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y +nombrando lo que falta** — no finge haberlo tomado. + +⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un +sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del +daemon, al pie, para que nadie lo lea como «probado de punta a punta». + ### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07) Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf` @@ -1352,7 +1414,7 @@ siguiente. | 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 — 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 personal — la mitad de congelar y buscar, HECHA 2026-09-10.** `archive.add`/`archive.search` + la extensión `archivo@atuq.tawasuyu`: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián `scripts/test-atuq-archivo.py`, cuyo control negativo además **dice quién** impidió archivar lo privado. **Falta la mitad semántica**: un motor `rag-motor::RagMotor` (como `willay-rag`), que exige daemon de embeddings + LLM real | 6.3 | 5 ✅, 7 ✅ | -| 9 | **Medios fuera del navegador (6.6) — HECHA 2026-09-10**: `media.open` + la extensión `medios@atuq.tawasuyu`; una navegación de primer nivel a `video/*`/`audio/*` la abre `mpv`, que ya viaja en las cuatro imágenes. Fail-open probado: sin reproductor la navegación VUELVE al navegador. Destapó que un hijo hereda la tubería del protocolo. **Faltan** el foco (6.5), la IA local (6.7) y el torrent (6.9) | 6.5–6.7, 6.9 | 5 ✅ | +| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ | | 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — | Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la diff --git a/recipes/atuq.toml b/recipes/atuq.toml index 6c1ad617..50adc35d 100644 --- a/recipes/atuq.toml +++ b/recipes/atuq.toml @@ -181,7 +181,10 @@ grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no # sin esta línea la imagen llevaría el manifiesto y la extensión, y el host NO estaría — la función # se apagaría sola sin una línea de error. `runtime` no entra en el ArtifactHash: lo que cambia es la # CLAUSURA, que es lo que se hidrata. -runtime = ["gcc-libs", "puriy-costura"] +# Y `puriy-costura-torrent` (§6.9): el brazo LARGO de la costura. Va como dep de RUNTIME y no como +# servicio — es PEREZOSO a propósito: el binario está en la imagen y no corre nada hasta que alguien +# hace clic en un `magnet:`. Un daemon que arranca con la sesión es un daemon que nadie pidió. +runtime = ["gcc-libs", "puriy-costura", "puriy-costura-torrent"] # `firefox` como dep de BUILD y no de runtime: su árbol se materializa en el sandbox, se copia # dentro del artefacto de atuq y a partir de ahí atuq no lo necesita más. El precio es que el # artefacto pesa lo que pesa Firefox otra vez; la salida barata (una granja de symlinks) rompe la diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index 000ae9a9..ba41c609 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -28,6 +28,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y | `extensions/descargas/` | `/distribution/extensions/descargas@atuq.tawasuyu.xpi` | **Descargas con identidad** (SDD 26 §6.2). | | `extensions/archivo/` | `/distribution/extensions/archivo@atuq.tawasuyu.xpi` | **Archivo personal** (SDD 26 §6.3). | | `extensions/medios/` | `/distribution/extensions/medios@atuq.tawasuyu.xpi` | **Medios fuera del navegador** (SDD 26 §6.6). | +| `extensions/torrent/` | `/distribution/extensions/torrent@atuq.tawasuyu.xpi` | **Torrent con identidad** (SDD 26 §6.9). | | `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. | | `bin/puriy-costura-host` | `/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. | @@ -239,6 +240,27 @@ sin pestaña es el único resultado inaceptable, y el control negativo del guard ⚠ 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 `--`. +## `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 @@ -300,8 +322,9 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control `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. -- **Los otros verbos del §6** sobre el host que ya está puesto: torrent (6.9) e IA local en la barra - lateral (6.7). +- **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. +- **El foco por `cortafuegos` (6.5)**, el último del §6 que no depende de un modelo ni de un daemon. - **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. diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json index 160b9622..631e37f5 100644 --- a/recipes/atuq/distribution/policies.json +++ b/recipes/atuq/distribution/policies.json @@ -51,6 +51,10 @@ "medios@atuq.tawasuyu": { "installation_mode": "normal_installed", "install_url": "file:///usr/lib/atuq/distribution/extensions/medios@atuq.tawasuyu.xpi" + }, + "torrent@atuq.tawasuyu": { + "installation_mode": "normal_installed", + "install_url": "file:///usr/lib/atuq/distribution/extensions/torrent@atuq.tawasuyu.xpi" } } } diff --git a/recipes/atuq/extensions/torrent/fondo.js b/recipes/atuq/extensions/torrent/fondo.js new file mode 100644 index 00000000..047fffaf --- /dev/null +++ b/recipes/atuq/extensions/torrent/fondo.js @@ -0,0 +1,109 @@ +"use strict"; +// torrent — un magnet lo toma la suite (SDD 26 §6.9), v1. +// +// El navegador registra un handler para `magnet:` (`protocol_handlers` del +// manifiesto, que existe en nuestro build), así que un clic en un enlace magnet abre +// una página NUESTRA con el enlace en un parámetro. Esa página se lo manda a este +// fondo, y este fondo al host. +// +// ── POR QUÉ IMPORTA, Y NO ES «TENER TORRENT» ───────────────────────────────── +// Vivaldi ya trae torrent; termina en una carpeta. Lo que cambia acá es que lo que +// baja entra al CAS por el mismo camino del §6.2: un objeto BLAKE3 deduplicado y +// verificable, no un archivo con nombre. Por eso el verbo es del host y no una +// biblioteca dentro de la extensión. +// +// ⚠ NO SE INTERCEPTA UN `.torrent` POR `webRequest`, todavía: un fichero servido +// como `application/x-bittorrent` es una descarga, y esa ya la ve la extensión de +// descargas. Meter dos extensiones a decidir sobre la misma respuesta es la forma de +// que ninguna de las dos se entienda después. + +const HOST = "puriy_costura"; + +let puerto = null; +let pendiente = null; // { resolver, enlace } + +function di(s) { + try { dump("TORRENT " + s + "\n"); } catch (e) {} +} + +function conectar() { + try { + puerto = browser.runtime.connectNative(HOST); + puerto.onMessage.addListener(alRecibir); + puerto.onDisconnect.addListener(p => { + const causa = p.error ? p.error.message : "sin error"; + di("DESCONECTADO " + causa); + puerto = null; + if (pendiente) { + pendiente.resolver({ ok: false, error: "el host se desconectó: " + causa }); + pendiente = null; + } + }); + di("CONECTADO " + HOST); + } catch (e) { + di("SIN HOST " + e.message); + puerto = null; + } +} + +function alRecibir(r) { + if (!r || r.verb !== "torrent.add") { + if (r && r.ok === false && pendiente) { + di("RECHAZO " + (r.error || "sin error")); + pendiente.resolver({ ok: false, error: r.error || "sin detalle" }); + pendiente = null; + } + return; + } + // ⚠ Estas claves son las del DAEMON, no unas nuestras: el host pasa su respuesta + // tal cual. La primera versión leía `name`/`files`/`bytes` —que el daemon no + // manda— y el resultado fue un «TOMADO — 0 fichero(s), 0 bytes» que parecía un + // torrent vacío en vez de una clave mal escrita. Un vocabulario inventado del lado + // del lector es la misma trampa que tener la definición dos veces. + const t = r.torrent || {}; + const detalle = [ + t.name || "(sin nombre todavía)", + t.already_there ? "ya estaba en la sesión" : "nuevo", + t.folder ? "en " + t.folder : null, + typeof t.id === "number" ? "id=" + t.id : null, + ].filter(Boolean).join(" · "); + di("TOMADO " + detalle); + if (pendiente) { + pendiente.resolver({ ok: true, detalle }); + pendiente = null; + } +} + +browser.runtime.onMessage.addListener(msg => { + if (!msg || msg.tipo !== "magnet") { return; } + di("MAGNET " + msg.enlace.slice(0, 80)); + if (!puerto) { conectar(); } + if (!puerto) { + return Promise.resolve({ ok: false, error: "no hay host: la suite no está en esta imagen" }); + } + return new Promise(resolver => { + pendiente = { resolver, enlace: msg.enlace }; + try { + puerto.postMessage({ verb: "torrent.add", url: msg.enlace }); + } catch (e) { + pendiente = null; + resolver({ ok: false, error: e.message }); + } + // Un handler que se queda «hablando con la suite…» para siempre es peor que uno + // que dice que no pudo: el torrent puede tardar en resolver, pero la RESPUESTA + // del host no. + setTimeout(() => { + if (pendiente && pendiente.resolver === resolver) { + pendiente = null; + resolver({ ok: false, error: "la suite no contestó a tiempo" }); + } + }, 30000); + }); +}); + +conectar(); +// La URL base de la extensión, para que un guardián pueda abrir su página sin +// adivinar el UUID que Gecko le asigna por perfil. En producción no molesta: es una +// línea de `dump`, que sólo sale con la pref de depuración puesta. +try { di("BASE " + browser.runtime.getURL("")); } catch (e) {} +di("LISTA v1 toma magnet: y se lo pasa a la suite"); diff --git a/recipes/atuq/extensions/torrent/manifest.json b/recipes/atuq/extensions/torrent/manifest.json new file mode 100644 index 00000000..138de710 --- /dev/null +++ b/recipes/atuq/extensions/torrent/manifest.json @@ -0,0 +1,33 @@ +{ + "manifest_version": 2, + "name": "atuq — torrent con identidad", + "description": "Un `magnet:` no se lo lleva otro programa ni una web: lo toma la suite, y lo que baja entra al CAS (SDD 26 §6.9).", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "torrent@atuq.tawasuyu", + "strict_min_version": "115.0" + } + }, + "permissions": [ + "nativeMessaging", + "tabs" + ], + "protocol_handlers": [ + { + "protocol": "magnet", + "name": "atuq", + "uriTemplate": "/tomado.html?m=%s" + } + ], + "background": { + "scripts": ["fondo.js"] + }, + "browser_action": { + "default_title": "Torrent con identidad", + "default_icon": "atuq128.png" + }, + "icons": { + "128": "atuq128.png" + } +} diff --git a/recipes/atuq/extensions/torrent/tomado.html b/recipes/atuq/extensions/torrent/tomado.html new file mode 100644 index 00000000..4046a274 --- /dev/null +++ b/recipes/atuq/extensions/torrent/tomado.html @@ -0,0 +1,18 @@ + + +atuq — torrent + +
+

atuq tomó este enlace

+

Hablando con la suite…

+

+

+
+ diff --git a/recipes/atuq/extensions/torrent/tomado.js b/recipes/atuq/extensions/torrent/tomado.js new file mode 100644 index 00000000..75df764e --- /dev/null +++ b/recipes/atuq/extensions/torrent/tomado.js @@ -0,0 +1,33 @@ +"use strict"; +// La página que recibe el `magnet:`. Existe porque `protocol_handlers` funciona así: +// el navegador abre una URL NUESTRA con el enlace en un parámetro. Es también el +// único sitio donde el usuario ve qué pasó — un handler que no muestra nada deja la +// sensación de que el clic no hizo nada. +(async () => { + const enlace = new URLSearchParams(location.search).get("m") || ""; + document.getElementById("enlace").textContent = enlace; + const estado = document.getElementById("estado"); + const detalle = document.getElementById("detalle"); + if (!enlace) { + estado.textContent = "No vino ningún enlace."; + return; + } + // ⚠ La forma del origen NO se valida acá: la valida el host, que es el que + // decide qué le pasa al daemon. Repetir la regla en esta página sería tenerla + // escrita dos veces, y el día que una cambie la otra empezaría a mentir. Acá se + // muestra el resultado — que es lo único que esta página tiene que hacer. + + try { + const r = await browser.runtime.sendMessage({ tipo: "magnet", enlace }); + if (r && r.ok) { + estado.textContent = "La suite lo tomó."; + detalle.textContent = r.detalle || ""; + } else { + estado.textContent = "La suite no pudo tomarlo."; + detalle.textContent = (r && r.error) || "sin detalle"; + } + } catch (e) { + estado.textContent = "No pude hablar con la suite."; + detalle.textContent = e.message; + } +})(); diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json index 6a8fd5b3..85df956b 100644 --- a/recipes/atuq/native-messaging/puriy_costura.json +++ b/recipes/atuq/native-messaging/puriy_costura.json @@ -7,6 +7,7 @@ "sct@atuq.tawasuyu", "descargas@atuq.tawasuyu", "archivo@atuq.tawasuyu", - "medios@atuq.tawasuyu" + "medios@atuq.tawasuyu", + "torrent@atuq.tawasuyu" ] } diff --git a/recipes/puriy-costura-torrent.toml b/recipes/puriy-costura-torrent.toml new file mode 100644 index 00000000..65634b11 --- /dev/null +++ b/recipes/puriy-costura-torrent.toml @@ -0,0 +1,45 @@ +# puriy-costura-torrent — el brazo LARGO de la costura (SDD 26 §6.9): el daemon de torrent. +# +# ══ POR QUÉ ES UN BINARIO APARTE Y NO UN VERBO DENTRO DEL HOST ════════════════════════════════ +# Dos razones, y la primera decide: **una descarga tiene que sobrevivir al navegador**, y Gecko +# lanza un host de native messaging POR PUERTO y lo mata cuando el puerto se cierra (medido el +# 2026-09-10). Un cliente BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la +# pestaña. La segunda es el precio: librqbit + tokio + rustls son 226 crates, y ese host —952 K, +# compartido por las extensiones del navegador— pasaría a ~20 MB y cada iteración a un cuarto de +# hora. +# +# ⚠ Que esa pila COMPILA para musl con zig-cc se midió antes de decidir nada, con una receta +# desechable: 226 crates y sella. Era una incógnita real —ninguna receta de tawasuyu del corpus +# había construido tokio+rustls; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que +# la decisión no fue «no se puede», fue «no ahí». +# +# ══ PEREZOSO: ESTO NO ES UN SERVICIO ══════════════════════════════════════════════════════════ +# El artefacto es un binario y nada más: NO se declara ningún `.service`, ni nada que corra al +# iniciar sesión. Lo levanta su propio cliente la primera vez que hay un `magnet:` que tomar, y se +# va solo cuando no queda nada bajando. Un daemon que arranca con la sesión es un daemon que nadie +# pidió, y en una distro se nota. +# +# ══ EL COMMIT PINEADO ═════════════════════════════════════════════════════════════════════════ +# `3f69aab2` = el crate + el `Cargo.lock` cerrado (generado en un árbol LIMPIO del commit, que es +# la única forma de que describa `main` y no el árbol compartido de las sesiones). + +name = "puriy-costura-torrent" +version = "0.1.0" +license = "MIT OR Apache-2.0" + +[source] +repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git" +commit = "ca9947b92f89d00ca549d7bbaa3293ec909db7d8" +# Obligatorio para cualquier receta desde este monorepo: tawasuyu commitea su propio `vendor/` con +# un `smithay` parcheado por RUTA, y el `cargo vendor` de takana convertiría ese directorio en uno +# de reemplazo de fuente, donde cada crate tiene que traer `.cargo-checksum.json` — que ese patch +# nunca tuvo. El síntoma no menciona nada de esto (ver `puriy-costura.toml`). +cargo_vendor_dir = ".hammer-cargo-vendor" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +# Estático: lo lanza el navegador (a través del host) y no puede depender de encontrar un loader. +link = "static" +# `--bin` además de `-p` porque el crate tiene lib + bin y `cargo rustc` exige un target único. +flags = ["-p", "puriy-costura-torrent", "--bin", "puriy-costura-torrent"] diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml index a424909d..24319a6e 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 = "2e99d21675de7aa3b60bf4d9d22fd4c8b76ffe0c" +commit = "066b3761f7a7a18415b0a59cb671355953bb48a1" # ⚠ 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-torrent.py b/scripts/test-atuq-torrent.py new file mode 100755 index 00000000..17a6d3ef --- /dev/null +++ b/scripts/test-atuq-torrent.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""¿El navegador le entrega un torrent a la suite, y lo toma un daemon que le sobrevive? (SDD 26 §6.9) + + python3 scripts/test-atuq-torrent.py # el daemon lo toma + python3 scripts/test-atuq-torrent.py --negative-control # sin el daemon: lo dice, no lo finge + +── QUÉ SE EJERCITA ─────────────────────────────────────────────────────────────────────────── + página de la extensión → fondo → connectNative → puriy-costura + → /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está) + → daemon: alta en la sesión de librqbit + +El daemon **no estaba corriendo**: no hay servicio en la imagen. Que aparezca es la mitad de +«perezoso», y es lo que este guardián comprueba. + +── DOS COSAS QUE ESTE GUARDIÁN NO HACE, Y POR QUÉ ──────────────────────────────────────────── +1. **No usa un `magnet:` sino un `.torrent` servido por HTTP.** Dar de alta un magnet BLOQUEA + esperando la metadata del enjambre —lo dice el propio `foreign-torrent::resolve`— y sin peers eso + no llega nunca: el guardián mediría un timeout, no la cadena. Con un `.torrent` la metadata viene + en el fichero, el alta termina, y lo único que falta son los peers (que no hacen falta para + probar la costura). El `.torrent` se arma acá, en bencode a mano: un binario de prueba metido en + el repo es una dependencia que nadie revisa. +2. **No pasa por el despacho de protocolos de Gecko.** Un clic en un `magnet:` abre el diálogo de + «¿con qué lo abro?», que en headless no contesta nadie; eso es una elección del usuario, no + código nuestro. Se abre directamente la página de la extensión —la misma que el handler abriría— + descubriendo su URL base del `dump` de la primera corrida, porque el UUID lo asigna Gecko por + perfil y adivinarlo sería inventar. + +REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` + +`puriy-costura-torrent` sellados. +""" +import hashlib +import http.server +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/atuq-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")) +CLIENTE = "/usr/bin/puriy-costura-torrent" + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(receta, nombre, escotilla=None): + 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 + + +def bencode(v): + """Bencode, lo justo para un `.torrent` de un solo fichero.""" + if isinstance(v, int): + return b"i" + str(v).encode() + b"e" + if isinstance(v, bytes): + return str(len(v)).encode() + b":" + v + if isinstance(v, list): + return b"l" + b"".join(bencode(x) for x in v) + b"e" + if isinstance(v, dict): + salida = b"d" + for k in sorted(v): + salida += bencode(k) + bencode(v[k]) + return salida + b"e" + raise TypeError(type(v)) + + +CONTENIDO = b"un objeto con identidad, bajado por el enjambre\n" * 16 +PIEZA = 16384 + + +def torrent_de_prueba(puerto): + info = { + b"name": b"prueba-atuq.bin", + b"piece length": PIEZA, + b"length": len(CONTENIDO), + b"pieces": hashlib.sha1(CONTENIDO).digest(), + } + # El tracker no existe y no hace falta: el alta se completa con la metadata del + # propio fichero, que es justo lo que este guardián necesita. + return bencode({b"announce": f"http://127.0.0.1:{puerto}/announce".encode(), b"info": info}) + + +class Servidor(http.server.SimpleHTTPRequestHandler): + puerto = 0 + + def log_message(self, *a): + pass + + def do_GET(self): + if self.path.startswith("/prueba.torrent"): + datos, tipo = torrent_de_prueba(Servidor.puerto), "application/x-bittorrent" + else: + datos, tipo = b"ok", "text/html" + self.send_response(200) + self.send_header("Content-Type", tipo) + self.send_header("Content-Length", str(len(datos))) + 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, daemon, salida, url, negativo): + borrar = f"rm -f {CLIENTE}" if negativo else "true" + guion = f""" + mkdir -p /root /salida/hogar /salida/run + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id + export HOME=/salida/hogar XDG_RUNTIME_DIR=/salida/run XDG_STATE_HOME=/salida/hogar/.local/state + {borrar} + timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ + > /salida/consola.log 2>&1 + exit 0 + """ + capas = ["--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, + "--overlay-src", costura] + if daemon: + capas += ["--overlay-src", daemon] + subprocess.run([ + "bwrap", *capas, "--tmp-overlay", "/", + "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", + "--tmpfs", "/tmp", "--unshare-pid", + "--bind", salida, "/salida", + "--uid", "0", "--gid", "0", + "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin", + "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1", + "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1", + "sh", "-c", guion, + ], check=False) + log = open(os.path.join(salida, "consola.log"), errors="replace").read() + return [l[len("TORRENT "):] for l in log.splitlines() if l.startswith("TORRENT ")], log + + +def main(): + negativo = "--negative-control" in sys.argv[1:] + if negativo: + print("== MODO CONTROL NEGATIVO: sin el cliente del daemon; tiene que DECIRLO") + if not os.path.isdir(RFS): + fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`") + atuq = artefacto("atuq", "atuq", "ATUQ_DIR") + costura = artefacto("puriy-costura", "puriy-costura") + daemon = artefacto("puriy-costura-torrent", "puriy-costura-torrent") + print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}" + f"\n {os.path.basename(daemon)}") + + puerto = puerto_libre() + Servidor.puerto = puerto + servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor) + threading.Thread(target=servidor.serve_forever, daemon=True).start() + + tmp = tempfile.mkdtemp(prefix="atuq-torrent-") + salida = os.path.join(tmp, "salida") + os.makedirs(os.path.join(salida, "perfil")) + try: + with open(os.path.join(salida, "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') + + # Corrida 1: sólo para saber la URL base de la extensión (Gecko le asigna un + # UUID por perfil; adivinarlo sería inventar). + lineas, log = correr(atuq, costura, daemon, salida, "about:blank", negativo) + base = None + for l in lineas: + if l.startswith("BASE "): + base = l.split(" ", 1)[1].strip() + if not base: + destino = os.path.join(ROOT, "work", "atuq-torrent.log") + os.makedirs(os.path.dirname(destino), exist_ok=True) + open(destino, "w").write(log) + fatal(f"la extensión no dijo su URL base — ¿se instaló? log en {destino}") + print(f"\nextensión en {base}") + + # Corrida 2: su propia página, con el `.torrent` como origen. + origen = f"http://127.0.0.1:{puerto}/prueba.torrent" + pagina = f"{base}tomado.html?m={origen}" + lineas, log = correr(atuq, costura, daemon, salida, pagina, negativo) + for l in lineas: + print(" " + l) + + # La página no valida la forma del origen (lo hace el host, que es quien + # decide), así que le pasa el `.torrent` al fondo igual que un `magnet:`. Si + # no hubiera línea MAGNET, el mensaje no llegó al fondo y esto falla — que es + # lo correcto. + if not any(l.startswith("MAGNET ") for l in lineas): + destino = os.path.join(ROOT, "work", "atuq-torrent.log") + os.makedirs(os.path.dirname(destino), exist_ok=True) + open(destino, "w").write(log) + fatal(f"el fondo de la extensión no recibió el origen — log en {destino}") + + tomado = [l for l in lineas if l.startswith("TOMADO ")] + rechazo = [l for l in lineas if l.startswith("RECHAZO ")] + + if negativo: + if tomado: + fatal(f"sin el cliente del daemon dijo que lo tomó: {tomado}") + if not rechazo: + fatal("sin el cliente, el host tenía que fallar DICIÉNDOLO") + if "no hay cliente de torrent" not in " ".join(rechazo): + fatal(f"el motivo no nombra lo que falta: {rechazo}") + print("\n✓ control negativo: sin el daemon no se finge nada, y el motivo lo nombra") + return 0 + + if rechazo: + fatal(f"el host rechazó el torrent: {rechazo}") + if not tomado: + fatal("el daemon no acusó recibo del torrent") + if "prueba-atuq.bin" not in tomado[0]: + fatal(f"el daemon no leyó la metadata del .torrent: {tomado[0]}" + " — si dice «(sin nombre todavía)», el alta no trajo el nombre") + + # Y la prueba de la PEREZA: el daemon no estaba corriendo, y su socket + # apareció porque el cliente lo levantó. + sock = os.path.join(salida, "run", "puriy-costura-torrent.sock") + if not os.path.exists(sock): + fatal("no hay socket del daemon: no llegó a levantarse") + print(f"\nsocket del daemon: {os.path.relpath(sock, salida)}") + print("\n✓ el navegador le entrega el torrent a la suite y el daemon lo toma") + print("✓ y NADIE lo arrancó: no hay servicio en la imagen — lo levantó su cliente") + return 0 + finally: + servidor.shutdown() + shutil.rmtree(tmp, ignore_errors=True) + + +sys.exit(main())