> 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 §6.7.bis: la barra lateral pregunta, y contesta un modelo de ESTA máquina
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.
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 <ruta>.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/` | `<appdir>/distribution/extensions/medios@atuq.tawasuyu.xpi` | **Medios fuera del navegador** (SDD 26 §6.6). |
| `extensions/torrent/` | `<appdir>/distribution/extensions/torrent@atuq.tawasuyu.xpi` | **Torrent con identidad** (SDD 26 §6.9). |
| `extensions/foco/` | `<appdir>/distribution/extensions/foco@atuq.tawasuyu.xpi` | **Modo foco: lo muestra, no lo apaga** (SDD 26 §6.5). |
+| `extensions/ia/` | `<appdir>/distribution/extensions/ia@atuq.tawasuyu.xpi` | **IA local en la barra lateral** (SDD 26 §6.7). |
| `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
| `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
@@ -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 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>IA local</title>
+<link rel="stylesheet" href="panel.css">
+<main>
+ <p id="aviso">El modelo corre en esta máquina. No sale nada a la red.</p>
+ <div id="hilo"></div>
+ <form id="forma">
+ <textarea id="texto" rows="3" placeholder="Preguntá algo…"></textarea>
+ <button id="enviar" type="submit">Preguntar</button>
+ </form>
+</main>
+<script src="panel.js"></script>
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())