gioser

sergio/takana

> 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…

TOML 1411Shell 141Markdown 85Rust 842928 Definitions

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.

a14b62436c08678bfa88016928ae7613f0c86be5 · by Sergio · (1 month ago) · parent fefa92ec5 · browse the tree at this commit

Semantic delta · 10

recipes/atuq/extensions/ia/fondo.js

recipes/atuq/extensions/ia/panel.js

scripts/test-atuq-ia.py

11 files · +469 −2

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())