From a14b62436c08678bfa88016928ae7613f0c86be5 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 19:24:33 +0000 Subject: [PATCH] =?UTF-8?q?atuq=20=C2=A76.7.bis:=20la=20barra=20lateral=20?= =?UTF-8?q?pregunta,=20y=20contesta=20un=20modelo=20de=20ESTA=20m=C3=A1qui?= =?UTF-8?q?na?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inferencia REAL de punta a punta, en una jaula sin red: panel → fondo → host → `llama-server` (el llama-cpp del corpus) → respuesta. El servidor lo levanta el host en la primera pregunta —no hay servicio de IA en la imagen— y **se muere con el navegador**, al revés que el daemon del torrent (§6.9): un torrent tiene trabajo que sobrevive; medio giga de modelo en RAM, no. El puerto nativo vive en el FONDO y no en la página: si viviera en la página, cerrar la barra lateral se llevaría el host y el modelo cargado con él. `scripts/test-atuq-ia.py` mide tres cosas: que la respuesta salga del modelo de la imagen, que no venga VACÍA, y que al cerrarse el navegador queden cero `llama-server`. Control negativo: sin modelo aparece la causa y NO hay respuesta inventada. ⚠ Ese censo nació roto y del género que este repo colecciona: `pgrep -c` no existe en busybox y el `|| echo 0` convertía el error en un cero — o sea en un verde. Con un motor vivo a propósito el guardián pasaba igual. Ahora cuenta leyendo /proc y la rotura falla como debe. Y el PRIMER intento de romperlo tampoco rompía nada: el motor de mentira moría al hacer bind porque el socket estaba en /salida, compartido entre las dos corridas. Queda UNA decisión, no trabajo: qué modelo se pinea (tamaño de imagen y licencia). Hasta entonces la imagen no trae modelo y el panel lo dice con todas las letras. --- docs/26-atuq-envoltorio-gecko.md | 49 +++++ recipes/atuq/README.md | 24 +++ recipes/atuq/distribution/policies.json | 4 + recipes/atuq/extensions/ia/fondo.js | 87 ++++++++ recipes/atuq/extensions/ia/manifest.json | 26 +++ recipes/atuq/extensions/ia/panel.css | 10 + recipes/atuq/extensions/ia/panel.html | 13 ++ recipes/atuq/extensions/ia/panel.js | 50 +++++ .../atuq/native-messaging/puriy_costura.json | 3 +- recipes/puriy-costura.toml | 2 +- scripts/test-atuq-ia.py | 203 ++++++++++++++++++ 11 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 recipes/atuq/extensions/ia/fondo.js create mode 100644 recipes/atuq/extensions/ia/manifest.json create mode 100644 recipes/atuq/extensions/ia/panel.css create mode 100644 recipes/atuq/extensions/ia/panel.html create mode 100644 recipes/atuq/extensions/ia/panel.js create mode 100644 scripts/test-atuq-ia.py diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index aff6db46..5dcbcdae 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -961,6 +961,55 @@ barra lateral de IA. Falta, y tiene forma: `llama-cpp` **no está declarada en ningún perfil todavía**, y es a propósito: una imagen no debería crecer 199 M por una función que aún no existe. Se declara cuando el §6.7 se pague entero. +#### 6.7.bis La barra lateral YA PREGUNTA — y el modelo sigue siendo una decisión (2026-09-11) + +Lo que faltaba eran tres cosas; dos están hechas y la tercera es una decisión, no trabajo. + +**El verbo (`ai.ask`) y quién levanta el servidor.** Se resolvió **al revés que el torrent del §6.9**, +y la diferencia importa: allá el daemon sobrevive al navegador porque un torrent tiene trabajo que +sigue; acá el servidor **se muere con el navegador**, porque medio giga de modelo en RAM no es trabajo +pendiente, es peso. No hay servicio de IA en la imagen: el primer `ai.ask` levanta `llama-server` y el +host lo mata al salir. + +**El transporte es un socket UNIX**, no un puerto: `llama-server` acepta `--host .sock` +(medido). No colisiona, no queda expuesto a la red y no hay que buscar puerto libre. El cliente HTTP +son treinta líneas porque el servidor contesta con `Content-Length` — medido, y si algún día +contestara `chunked` **falla diciéndolo** en vez de entregar medio cuerpo. + +**Lo que pasa si a `puriy-costura` lo matan con `SIGKILL`**: el servidor queda. La red natural sería +`PR_SET_PDEATHSIG`, pero pedirlo exige `Command::pre_exec`, que es `unsafe`, y la caja lleva +`#![forbid(unsafe_code)]` — **una propiedad de la caja no se negocia por una comodidad de ciclo de +vida**. El residuo se ACOTA en su lugar: si al preguntar ya hay un servidor vivo **con el mismo +modelo**, se adopta; así lo peor que queda es uno, y la sesión siguiente lo hereda. Si el que está +vivo tiene OTRO modelo, se niega nombrándolos: contestar con un modelo que no es el de la imagen sería +mentir sobre quién contestó. + +**La barra lateral** (`extensions/ia/`) es una caja de texto y el hilo de respuestas. La página no +habla native messaging: habla con el fondo, y el fondo con el host — si el puerto nativo viviera en la +página, cerrar la barra lateral se llevaría el proceso del host **y el modelo cargado** con ella. +`panel.html?q=…` abre el panel con la pregunta ya hecha, que es la puerta por la que entrará +«preguntar sobre la selección» y la que usa el guardián. + +`scripts/test-atuq-ia.py` mide la cadena entera con **inferencia de verdad** —el `llama-cpp` del +corpus contra el modelo pineado del guardián del motor, importado de allá para que el sha256 viva en +un solo sitio— y dentro de una jaula **`--unshare-net`**: no hubo red para consultar a nadie más. Tres +aserciones: que la respuesta venga del modelo de la imagen, que **no venga vacía**, y que al cerrarse +el navegador queden **cero `llama-server`**. + +⚠ Ese censo nació roto y el fallo es del género que este documento colecciona: `pgrep -c` **no existe +en busybox**, y el `|| echo 0` que parecía prudente convertía el error en un **cero**, o sea en un +verde. Con un motor vivo a propósito, el guardián pasaba igual. Ahora se cuenta leyendo `/proc` a +mano, y la rotura a propósito falla como debe (`quedaron 1 llama-server vivos`). De paso, el primer +intento de romperlo **tampoco rompía nada**: el motor de mentira moría al hacer `bind` porque el +socket estaba en `/salida`, que se comparte entre las dos corridas; con el socket en `/tmp` —tmpfs +nuevo por corrida— la rotura rompe. + +**Lo que queda es UNA decisión: qué modelo se pinea.** No es trabajo pendiente: la cadena funciona y +el modelo entra por sha256 como el perfil de PGO de firefox. Son dos —uno de chat para esto y uno de +embeddings para el §6.3— y las dos preguntas que las gobiernan son el tamaño de la imagen y la +licencia. Hasta que se decidan, la imagen no trae modelo y la barra lateral **lo dice**: +`no hay modelo en /usr/share/takana/ia/modelo.gguf: esta imagen no trae ninguno pineado`. + ### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06) **Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index bca0ee85..7976d61e 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -30,6 +30,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y | `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). | | `extensions/foco/` | `/distribution/extensions/foco@atuq.tawasuyu.xpi` | **Modo foco: lo muestra, no lo apaga** (SDD 26 §6.5). | +| `extensions/ia/` | `/distribution/extensions/ia@atuq.tawasuyu.xpi` | **IA local en la barra lateral** (SDD 26 §6.7). | | `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. | | `bin/puriy-costura-host` | `/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. | @@ -241,6 +242,29 @@ 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 `--`. +## `ia` — la barra lateral le pregunta a un modelo de ESTA máquina (§6.7) + +Sin nube, sin clave de API y sin un byte saliendo: el host levanta un `llama-server` (el `llama-cpp` +del corpus) contra el modelo pineado en `/usr/share/takana/ia/modelo.gguf` y habla con él por un +**socket UNIX**. No hay servicio de IA en la imagen: el primer `ai.ask` lo levanta, y **se muere con +el navegador** — al revés que el daemon del torrent (§6.9), porque un torrent tiene trabajo que +sobrevive y medio giga de modelo en RAM no. + +El puerto nativo vive en el **fondo**, no en la página del panel: si viviera en la página, cerrar la +barra lateral se llevaría el proceso del host y el modelo cargado con ella. `panel.html?q=…` abre el +panel con la pregunta ya hecha. + +Cuando algo falta, el panel **muestra la causa** («no hay modelo en …», «no hay motor de inferencia +en …»). Una IA que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario +no puede distinguir «no hay modelo» de «el modelo dijo eso». + +`scripts/test-atuq-ia.py` mide la cadena entera con inferencia REAL, en una jaula `--unshare-net`, y +comprueba además que al cerrarse el navegador queden **cero** `llama-server`. Control negativo: sin +modelo, la causa aparece y **no hay respuesta inventada**. + +⚠ **La imagen todavía no trae modelo**, y es una decisión pendiente (tamaño y licencia), no trabajo: +ver el §6.7 del SDD 26. + ## `foco` — el navegador MUESTRA el foco, y no tiene con qué apagarlo (§6.5) El foco no es un bloqueador de sitios: es una regla `nft` de **egress por cgroup** que aplicó root, y diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json index f261d356..4bb24e00 100644 --- a/recipes/atuq/distribution/policies.json +++ b/recipes/atuq/distribution/policies.json @@ -59,6 +59,10 @@ "foco@atuq.tawasuyu": { "installation_mode": "normal_installed", "install_url": "file:///usr/lib/atuq/distribution/extensions/foco@atuq.tawasuyu.xpi" + }, + "ia@atuq.tawasuyu": { + "installation_mode": "normal_installed", + "install_url": "file:///usr/lib/atuq/distribution/extensions/ia@atuq.tawasuyu.xpi" } } } diff --git a/recipes/atuq/extensions/ia/fondo.js b/recipes/atuq/extensions/ia/fondo.js new file mode 100644 index 00000000..73750088 --- /dev/null +++ b/recipes/atuq/extensions/ia/fondo.js @@ -0,0 +1,87 @@ +"use strict"; +// ia — el puente entre la barra lateral y el modelo LOCAL (SDD 26 §6.7), v1. +// +// La página de la barra lateral no habla native messaging: habla con este fondo, y +// el fondo con `puriy-costura`. No es ceremonia — el puerto nativo tiene que +// sobrevivir a que la barra lateral se abra y se cierre, y una página que se +// destruye con cada cierre se llevaría el proceso del host (y el modelo cargado) +// con ella. +// +// ── LO QUE ESTA EXTENSIÓN NO HACE ──────────────────────────────────────────── +// No manda NADA a ninguna nube, y no puede: el host habla con un `llama-server` +// que corre en esta máquina, por un socket UNIX. Tampoco manda la página que estás +// viendo: lo único que viaja es lo que escribiste. + +const HOST = "puriy_costura"; + +let puerto = null; +let siguiente = 1; +// id de petición → resolve, para devolverle a la barra lateral SU respuesta y no +// la primera que llegue. +const enVuelo = new Map(); + +function di(s) { + try { dump("IA " + s + "\n"); } catch (e) {} +} + +function conectar() { + try { + puerto = browser.runtime.connectNative(HOST); + } catch (e) { + di("SIN HOST " + e); + return false; + } + di("CONECTADO " + HOST); + puerto.onMessage.addListener((r) => { + const pendiente = r && r.id ? enVuelo.get(r.id) : null; + if (!pendiente) return; + enVuelo.delete(r.id); + if (r.ok) { + di("RESPUESTA tokens=" + (r.tokens === undefined ? "?" : r.tokens) + + " modelo=" + (r.model || "?") + " texto=" + JSON.stringify(r.answer || "")); + } else { + di("ERROR " + (r.error || "sin causa")); + } + pendiente(r); + }); + puerto.onDisconnect.addListener(() => { + const e = puerto && puerto.error ? puerto.error.message : "sin causa"; + di("DESCONECTADO " + e); + puerto = null; + // Al caerse el host se cae su `llama-server`, así que lo que estaba en vuelo no + // va a volver nunca: se contesta con la causa en vez de dejar la barra lateral + // esperando para siempre. + for (const [id, resolver] of enVuelo) { + enVuelo.delete(id); + resolver({ ok: false, error: "se cortó la conexión con el host: " + e }); + } + }); + return true; +} + +function preguntar(texto) { + if (!puerto && !conectar()) { + return Promise.resolve({ ok: false, error: "no hay host nativo en esta imagen" }); + } + const id = siguiente++; + di("PREGUNTA id=" + id + " " + JSON.stringify(texto)); + return new Promise((resolver) => { + enVuelo.set(id, resolver); + try { + puerto.postMessage({ id, verb: "ai.ask", prompt: texto }); + } catch (e) { + enVuelo.delete(id); + resolver({ ok: false, error: "no pude enviar la pregunta: " + e }); + } + }); +} + +browser.runtime.onMessage.addListener((msg) => { + if (!msg || msg.tipo !== "preguntar" || typeof msg.texto !== "string") return; + return preguntar(msg.texto); +}); + +di("LISTA v1 el modelo corre en esta máquina; no sale nada a la red"); +// La URL base la asigna Gecko por perfil: decirla es lo que deja abrir el panel desde +// fuera (y lo que usa el guardián para no adivinar un UUID). +di("BASE " + browser.runtime.getURL("")); diff --git a/recipes/atuq/extensions/ia/manifest.json b/recipes/atuq/extensions/ia/manifest.json new file mode 100644 index 00000000..8531c134 --- /dev/null +++ b/recipes/atuq/extensions/ia/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 2, + "name": "atuq — IA local", + "description": "Una barra lateral que le pregunta a un modelo que corre EN ESTA MÁQUINA (SDD 26 §6.7).", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "ia@atuq.tawasuyu", + "strict_min_version": "115.0" + } + }, + "permissions": [ + "nativeMessaging" + ], + "background": { + "scripts": ["fondo.js"] + }, + "sidebar_action": { + "default_title": "IA local", + "default_panel": "panel.html", + "default_icon": "atuq128.png" + }, + "icons": { + "128": "atuq128.png" + } +} diff --git a/recipes/atuq/extensions/ia/panel.css b/recipes/atuq/extensions/ia/panel.css new file mode 100644 index 00000000..74a3eebe --- /dev/null +++ b/recipes/atuq/extensions/ia/panel.css @@ -0,0 +1,10 @@ +:root { color-scheme: light dark; } +body { font: 13px/1.5 system-ui, sans-serif; margin: 0; } +main { display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; padding: 8px; } +#aviso { margin: 0 0 8px; opacity: .7; font-size: 11px; } +#hilo { flex: 1; overflow-y: auto; } +.turno { margin-bottom: 10px; white-space: pre-wrap; word-break: break-word; } +.turno.yo { opacity: .75; } +.turno.error { color: #b3261e; } +form { display: flex; gap: 6px; } +textarea { flex: 1; resize: vertical; font: inherit; } diff --git a/recipes/atuq/extensions/ia/panel.html b/recipes/atuq/extensions/ia/panel.html new file mode 100644 index 00000000..cc56f647 --- /dev/null +++ b/recipes/atuq/extensions/ia/panel.html @@ -0,0 +1,13 @@ + + +IA local + +
+

El modelo corre en esta máquina. No sale nada a la red.

+
+
+ + +
+
+ diff --git a/recipes/atuq/extensions/ia/panel.js b/recipes/atuq/extensions/ia/panel.js new file mode 100644 index 00000000..23b7df5f --- /dev/null +++ b/recipes/atuq/extensions/ia/panel.js @@ -0,0 +1,50 @@ +"use strict"; +// La barra lateral: una caja de texto y lo que contestó el modelo local. +// +// `?q=…` abre el panel con la pregunta ya hecha. Es la puerta por la que entran las +// preguntas que NO se tipean acá (y la que usa el guardián); no es superficie de +// prueba: sin ella, «preguntarle a la IA sobre esto» tendría que reescribir la +// pregunta a mano. + +function agregar(clase, texto) { + const d = document.createElement("div"); + d.className = "turno " + clase; + d.textContent = texto; + document.getElementById("hilo").appendChild(d); + d.scrollIntoView({ block: "end" }); + return d; +} + +async function preguntar(texto) { + if (!texto.trim()) return; + agregar("yo", texto); + const esperando = agregar("", "pensando…"); + const boton = document.getElementById("enviar"); + boton.disabled = true; + let r; + try { + r = await browser.runtime.sendMessage({ tipo: "preguntar", texto }); + } catch (e) { + r = { ok: false, error: String(e) }; + } + boton.disabled = false; + // La causa se MUESTRA. «No pude contestar» a secas dejaría al usuario sin saber + // si falta el modelo, si el motor no está en la imagen o si se cayó el host. + if (!r || !r.ok) { + esperando.className = "turno error"; + esperando.textContent = (r && r.error) || "el host no contestó"; + return; + } + esperando.textContent = r.answer; +} + +document.getElementById("forma").addEventListener("submit", (ev) => { + ev.preventDefault(); + const caja = document.getElementById("texto"); + const texto = caja.value; + caja.value = ""; + preguntar(texto); +}); + +const q = new URLSearchParams(location.search).get("q"); +if (q) preguntar(q); diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json index 8416e7fc..2269fc88 100644 --- a/recipes/atuq/native-messaging/puriy_costura.json +++ b/recipes/atuq/native-messaging/puriy_costura.json @@ -9,6 +9,7 @@ "archivo@atuq.tawasuyu", "medios@atuq.tawasuyu", "torrent@atuq.tawasuyu", - "foco@atuq.tawasuyu" + "foco@atuq.tawasuyu", + "ia@atuq.tawasuyu" ] } diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml index 70826361..89ed036c 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 = "38815b5f3625d7d61dd4005195e3f822d11fc380" +commit = "fea0e89036fda8dcdd61191f3953a50e93b1e6a6" # ⚠ 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-ia.py b/scripts/test-atuq-ia.py new file mode 100644 index 00000000..1c68104c --- /dev/null +++ b/scripts/test-atuq-ia.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""¿La barra lateral le pregunta a un modelo que corre EN ESTA MÁQUINA? (SDD 26 §6.7) + + python3 scripts/test-atuq-ia.py # sale una respuesta generada acá + python3 scripts/test-atuq-ia.py --negative-control # sin modelo: lo DICE, no inventa + +── QUÉ SE EJERCITA, DE PUNTA A PUNTA ───────────────────────────────────────────────────────── +El navegador abre el panel de la extensión con una pregunta (`panel.html?q=…`), el panel se la pasa +al fondo, el fondo al host por native messaging, el host levanta un `llama-server` —que NO estaba +corriendo: no hay servicio de IA en la imagen— y la respuesta vuelve por el mismo camino. La +inferencia es REAL: tokens generados por `llama-cpp` del corpus, contra un modelo pineado por +sha256, dentro de la jaula y sin red. + +El modelo de prueba es el MISMO que pinea `scripts/test-llama-cpp.py`, importado de ahí para que el +sha256 viva en un solo sitio: `stories260K`, 1,1 MB, que dice tonterías con gramática — que es +exactamente lo que hace falta para comprobar el CAMINO sin pinear un modelo de producción, que es +otra decisión (y otros cientos de megas). + +── EL CONTROL NEGATIVO ─────────────────────────────────────────────────────────────────────── +`--negative-control` borra el modelo de la jaula y exige que el panel muestre **la causa**. Una IA +que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario no puede +distinguir «no hay modelo» de «el modelo dijo eso». + +REQUISITOS: el rootfs mínimo de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` sellados. +""" +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile + +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")) +# El path de PRODUCCIÓN del modelo, el mismo que la constante del host. Se mide éste y no la +# escotilla de pruebas: una escotilla mide el código, no el contrato con la imagen. +MODELO_EN_IMAGEN = "/usr/share/takana/ia/modelo.gguf" +PREGUNTA = "Once upon a time" + + +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") + if not os.listdir(d): + fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre") + return d + + +def modelo_de_prueba(): + """El fetcher del guardián del motor, reusado: así el sha256 del modelo vive en UN sitio.""" + ruta = os.path.join(ROOT, "scripts", "test-llama-cpp.py") + spec = importlib.util.spec_from_file_location("guardian_llama", ruta) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # tiene guarda `__main__`: importarlo no corre nada + return str(mod.bajar_modelo()) + + +def correr(atuq, costura, llama, modelo, salida, url, negativo): + borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true" + guion = f""" + mkdir -p /root /salida/hogar /salida/run {os.path.dirname(MODELO_EN_IMAGEN)} + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id + export HOME=/salida/hogar XDG_RUNTIME_DIR=/salida/run + cp /salida/modelo.gguf {MODELO_EN_IMAGEN} + {borrar} + timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ + > /salida/consola.log 2>&1 + # ¿Se fue el motor con el navegador? Se cuenta ACÁ ADENTRO, con el namespace de PIDs todavía + # vivo: desde el hub no se vería nada —`--unshare-pid` se lleva todo al cerrar la jaula— y esa + # limpieza del arnés taparía justo la promesa que hay que medir. Medio giga de modelo que + # sobrevive al navegador no se nota hasta que la máquina se queda sin RAM. + # Se cuenta leyendo /proc a mano, y no con `pgrep -c`: busybox NO tiene esa opción, y el + # `|| echo 0` que parecía prudente convertía el error en un CERO — o sea en un veredicto verde. + # Medido: con un motor vivo a propósito, el guardián pasaba igual. + n=0 + for p in /proc/[0-9]*; do + [ "$(cat $p/comm 2>/dev/null)" = "llama-server" ] && n=$((n+1)) + done + echo $n > /salida/motores-vivos + exit 0 + """ + subprocess.run([ + "bwrap", + "--overlay-src", ALPINE, "--overlay-src", RFS, + "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", llama, + "--tmp-overlay", "/", + "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", + "--tmpfs", "/tmp", "--unshare-pid", "--unshare-net", + "--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() + vivos = "?" + try: + vivos = open(os.path.join(salida, "motores-vivos"), errors="replace").read().strip() + except OSError: + pass + return [l[len("IA "):] for l in log.splitlines() if l.startswith("IA ")], log, vivos + + +def main(): + negativo = "--negative-control" in sys.argv[1:] + if negativo: + print("== MODO CONTROL NEGATIVO: sin modelo en la imagen; el panel tiene que DECIR la causa") + 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") + llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR") + print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}" + f"\n {os.path.basename(llama)}") + modelo = modelo_de_prueba() + print(f"modelo de prueba: {os.path.basename(modelo)}") + + tmp = tempfile.mkdtemp(prefix="atuq-ia-") + salida = os.path.join(tmp, "salida") + os.makedirs(os.path.join(salida, "perfil")) + shutil.copy(modelo, os.path.join(salida, "modelo.gguf")) + 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') + + # Primera corrida: descubrir la URL base que Gecko le asignó a la extensión (es por perfil). + lineas, log, _ = correr(atuq, costura, llama, modelo, salida, "about:blank", negativo) + base = next((l[len("BASE "):].strip() for l in lineas if l.startswith("BASE ")), None) + if not base: + destino = os.path.join(ROOT, "work", "atuq-ia.log") + os.makedirs(os.path.dirname(destino), exist_ok=True) + shutil.copy(os.path.join(salida, "consola.log"), destino) + fatal(f"la extensión no dijo su URL base — log en {destino}") + print(f"\nextensión en {base}") + + panel = f"{base}panel.html?q={PREGUNTA.replace(' ', '%20')}" + lineas, log, motores = correr(atuq, costura, llama, modelo, salida, panel, negativo) + for l in lineas: + print(" " + l) + + errores = [l for l in lineas if l.startswith("ERROR ")] + if negativo: + if not errores: + fatal("sin modelo no dijo nada: el panel se habría quedado pensando para siempre") + if "no hay modelo" not in errores[0]: + fatal(f"la causa no nombra lo que falta: {errores[0]}") + if any(l.startswith("RESPUESTA ") for l in lineas): + fatal("sin modelo CONTESTÓ igual: eso es inventar una respuesta") + print("\n✓ control negativo: sin modelo lo dice, nombrando lo que falta") + print("✓ y no inventa una respuesta, que es el único fallo inaceptable en esto") + return 0 + + if errores: + fatal(f"el host falló: {errores[0]}") + respuestas = [l for l in lineas if l.startswith("RESPUESTA ")] + if not respuestas: + fatal("no volvió ninguna respuesta del modelo") + r = respuestas[0] + if f"modelo={MODELO_EN_IMAGEN}" not in r: + fatal(f"la respuesta no sale del modelo de la imagen: {r}") + # Un `texto=""` es lo que devolvería un camino que «funciona» sin generar nada. + texto = r.split("texto=", 1)[1] if "texto=" in r else "" + if len(texto.strip(' "')) < 2: + fatal(f"la respuesta vino VACÍA: {r}") + if motores != "0": + fatal(f"quedaron {motores} `llama-server` vivos al cerrarse el navegador —" + " el motor tiene que irse con él (§6.7): medio giga de modelo huérfano no se ve") + print("\n✓ el navegador preguntó y un modelo de ESTA máquina contestó") + print("✓ y nadie levantó el servidor: no hay servicio de IA en la imagen — lo levantó el host") + print("✓ la jaula corre con `--unshare-net`: no hubo red para consultar a nadie más") + print("✓ y el motor se fue con el navegador: 0 `llama-server` vivos al cerrarse") + return 0 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +sys.exit(main())