Commit Graph
4 Commits
Author SHA1 Message Date
Sergio 928d5eb119 atuq: un vídeo se abre en el reproductor de la distro, no en una pestaña
El 6.6 de la unidad 9 del SDD 26. Una navegación de PRIMER NIVEL a un medio
(`Content-Type: video/*` o `audio/*`) se cancela y la URL se la lleva `mpv`, que ya viaja en las
cuatro imágenes de escritorio: esto no agrega ni una receta, es cablear lo que ya estaba.

    MEDIO http://…/audio.wav
    CANCELADA la navegación http://…/audio.wav
    ABIERTO /usr/bin/mpv pid=266 http://…/audio.wav
    mpv: AO: [null] 8000Hz mono 1ch u8        ← su propio log: DECODIFICÓ, no sólo arrancó

LA REGLA ES ESTRECHA A PROPÓSITO: 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.

FAIL-OPEN, y es lo que prueba el control negativo: cancelar es SÍNCRONO y la respuesta del host llega
después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo con el
puerto vivo y, si el host contesta que no pudo, la URL VUELVE al navegador con una marca para no
entrar en bucle:

    SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
    VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no

Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es justo el que se consigue si
uno confía en que salió bien.

⚠ EL BUG QUE ESTE GUARDIÁN DESTAPÓ VALE MÁS QUE LA FUNCIÓN, y estaba DESPUÉS del éxito aparente: la
primera corrida detectó el medio, canceló y recibió el pid… y después el puerto murió con «Native
application tried to send a message of 546281442 bytes». **Un hijo hereda los descriptores del padre,
y los del padre SON la tubería de native messaging**: `mpv` escribía su salida ahí y el navegador la
leía como un marco. Arreglado en tawasuyu (`2e99d216`, pineado acá) con tres redirecciones, cada una
contra un fallo distinto — `stdin` a null porque mpv LEE stdin y se comería los mensajes del
navegador; `stdout` al stderr del host y no a `/dev/null`, porque apagarlo arreglaría el bug y se
llevaría puesto el diagnóstico; `stderr` heredado por lo mismo.

Y dos cosas del ARNÉS, las dos ya vistas antes en esta misma sesión:
· la URL NO se pasa por la línea de comandos: una petición del arranque compite con la
  inicialización de la extensión (la misma carrera del guardián de `sct`). El guardián navega a una
  página que redirige a los 3 s, que además es lo que hace una persona: seguir un enlace;
· la evidencia de que el reproductor CORRIÓ se le pide a su propio `log-file`, porque su salida ya no
  va al stdout del host y Gecko no vuelca el stderr del host al del navegador. Sin eso sólo se sabría
  que hubo un `spawn`.

Del §6 quedan el foco (6.5), la IA local (6.7) —que además es la que traería el motor que le falta al
archivo del §6.3— y el torrent (6.9). Medido de paso: **el corpus no tiene ninguna receta de LLM ni de
embeddings**, así que 6.7 hoy no se puede pagar.

Medido sobre `atuq b3:a7080058` y `puriy-costura b3:060314e6`.
2026-09-10 15:45:08 +00:00
Sergio 7f6459c132 atuq: el archivo personal — lo que leés queda congelado, y se encuentra
La unidad 8 del SDD 26 (§6.3), en su mitad pagable. Cada página que se lee se congela: el HTML **ya
ejecutado** —lo que la persona vio, 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, sin el navegador.

    visita 1 (página A)  ⇒ archivada, visitas=1, total=1
    visita 2 (página A)  ⇒ dedup, visitas=2, total=1     ← volver NO duplica
    visita 3 (página B)  ⇒ total=2
    «masa» → 1 de 2 · «tobera» → 1 de 2 · «masa tobera» → 0 · «helicóptero» → 0

LA IDENTIDAD ES EL BLAKE3 DEL HTML, NO LA URL, y de ahí sale lo que hace que esto valga: la misma URL
con otro contenido ES otra página, así que el archivo tiene VERSIONES de lo que leíste. Un historial
de direcciones guarda punteros, y los punteros se pudren.

⚠ LA MITAD QUE NO SE PAGA HOY, dicha en el código, en el LEEME de allá y en el §6.3.bis: preguntarle
al historial en LENGUAJE NATURAL. Eso es el registro semántico y en la suite ya tiene forma —un motor
`rag-motor::RagMotor`, como `willay-rag`—, que necesita un daemon de embeddings y un backend LLM real
y devuelve `None` cuando no están. `archive.search` es el registro LITERAL: todas las palabras tienen
que aparecer. Enseñar lo uno diciendo que es lo otro sería el peor cambio posible.

LO QUE NO SE ARCHIVA ES LA PARTE QUE HAY QUE MIRAR: nada de una ventana privada, nada fuera del marco
principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto visible (el
HTML de un visor de PDF llenaría el archivo de cosas que no se encuentran). Y si no se puede saber si
la pestaña es privada, NO se archiva.

⚠ Y EL CONTROL NEGATIVO NO SÓLO COMPRUEBA QUE NO SE ARCHIVE: DICE QUIÉN LO IMPIDIÓ. La respuesta
medida no es la que uno supondría — hoy lo impide **el navegador**, porque las extensiones no corren
en ventanas privadas salvo que se las habilite, así que nuestro `if (incognito) return` no se ejecuta
nunca. No es código muerto: es lo que haría seguro habilitar `private_browsing` el día que haga
falta. Lo que sí habría sido un error es escribir «la extensión no archiva lo privado» sin saber cuál
de las dos cosas estaba pasando.

DOS TECHOS CON NOMBRE (en el host): 4 KiB de texto por página en el índice —es JSON para poder leerse
con `cat`, y uno sin techo deja de poder— y el corte por CARÁCTER y no por byte, porque `truncate`
sobre medio multibyte entra en pánico y en un archivo personal el texto con acentos es el caso normal.

Del lado de tawasuyu (`357791a8`, pineado acá): `archive.add`/`archive.search`, 6 tests nuevos (20 en
total) y el CAS renombrado de `descargas-cas` a `cas` — el mismo almacén guarda ahora lo bajado y lo
leído, y un nombre que describe la mitad de su contenido es el que se lee mal dentro de seis meses.

Medido sobre `atuq b3:5cfe3221` y `puriy-costura b3:c06f55aa`.
2026-09-10 14:58:50 +00:00
Sergio 897fad178a 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.
2026-09-10 14:16:32 +00:00
Sergio 02513b861f atuq: sct v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera,
medida de punta a punta con un servidor HTTP real y seis cargas de página:

    servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
                  → puriy-costura --state → puriy-sct (TOFU + bitácora)

    carga 1-3 (mismo script)          fase=learning  eventos=0   insignia vacía
    carga 4   (mismo script)          fase=stable    eventos=0   insignia vacía
    carga 5   (mismo script, estable) fase=stable    eventos=0   insignia vacía   ← control
    carga 6   (script CAMBIADO)       fase=stable    eventos=1   insignia "1"
              EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B

La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, 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.

CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas:

1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de
   `XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko;
2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace
   `command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state`
   el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el
   lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de
   un usuario es layout del FHS, o sea asunto de la distro y no del crate;
3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja`
   (se le preguntó al artefacto, no a la documentación de Mozilla);
4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una
   PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN;
5. ⚠ **una carga de página puede producir dos peticiones 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 conocer su código real,
   y entonces alerta por churn legítimo — el falso positivo que la spec de puriy-sct pide evitar por
   encima de todo. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error
   seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga,
   una visita», así que si vuelve, falla ruidoso.

DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable
del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no
bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje 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 SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla,
y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única
parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final.

CONTROL NEGATIVO: `--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.

Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada
despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se
cierra.

Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable
dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y
de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las
extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta.

`runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y
el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que
llega a las cuatro imágenes de escritorio.

⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya
decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos
cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni
con el de la v2, que engancha el script loader y ve los bytes reales.

Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre
el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y
la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después
la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre
un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la
del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta.

Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen
matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen
siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`.
2026-09-10 01:57:07 +00:00