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…
⎇ 26c153c149c81663140768bfda7dafb19c010e4f
scripts/test-atuq-boveda-metal.py
Historia
Quién tocó qué
crudo
atuq: la bóveda estaba SELLADA y en ninguna imagen — y estar en la imagen no es poder abrirla
1#!/usr/bin/env python3
2"""¿La bóveda de `atuq` entrega una contraseña, y sólo si una persona dice que sí? (SDD 26 §7.sexies)
3
4 python3 scripts/test-atuq-boveda-metal.py # las seis etapas, en orden
5 python3 scripts/test-atuq-boveda-metal.py --hasta C # sólo hasta la etapa C (el diálogo)
6 python3 scripts/test-atuq-boveda-metal.py --list # qué afirma cada etapa, sin correr nada
7
8── POR QUÉ ESTE GUARDIÁN, Y POR QUÉ NO ALCANZA CON EL DE COHERENCIA ──────────────────────────
9`test-atuq-boveda-coherente.py` mira los SEIS lugares donde una extensión se enchufa y tarda un
10segundo. Es lo que hay que correr primero y no reemplaza a nada. Lo que no puede contestar es si del
11otro lado del cable hay alguien: el 2026-09-18 los cinco lugares que entonces se miraban estaban
12en orden —manifiesto, política, permiso, prefs y el commit del host con sus verbos— y la bóveda
13contestaba **cerrada** en las cuatro imágenes, porque el DUEÑO de la base y el DIÁLOGO de
14consentimiento no estaban en el corpus. Ningún guardián de FICHEROS podía verlo; éste empieza
15justamente por ahí. (El sexto lugar nació de eso: desde el 2026-09-21 aquél mira además quién
16DECLARA al dueño en la imagen, que es la mitad del §7.sexies que sí se puede ver sin construir
17nada — y la que volvió a morder el 2026-09-21, con las dos recetas selladas y en `perfiles: []`.)
18
19── LAS SEIS ETAPAS, Y QUÉ AFIRMA CADA UNA ────────────────────────────────────────────────────
20 A sin la app `boveda`, el host contesta `locked:true` ← el control, y es el bug del §7.sexies
21 B con la app corriendo, `vault.status` dice ABIERTA ← el dueño existe y atiende el socket
22 C `vault.save` abre el DIÁLOGO y se lo puede contestar ← el consentimiento es real y se ve
23 D el navegador, sobre una página servida por HTTP, encuentra la credencial (`vault.match`)
24 E `vault.fill` contestado que SÍ ⇒ la contraseña llega al campo del formulario
25 F `vault.fill` contestado que NO ⇒ `denied`, y el campo queda VACÍO ← el control de E
26
27La etapa A no es decoración: **«cerrada» es una respuesta exitosa** (`ok:true`), así que sin ese
28control un guardián que midiera sólo B no distinguiría «la bóveda abrió» de «nadie preguntó bien».
29Y la F es la que hace que la E signifique algo: una bóveda que entrega siempre se ve idéntica a una
30que entrega con permiso, y la diferencia es la única razón por la que esto existe.
31
32── LO QUE HIZO FALTA PARA PODER CONTESTAR UNA PREGUNTA ───────────────────────────────────────
33El corpus no tenía **ninguna** forma de meter una tecla en un compositor. Todos los arneses de
34`scripts/wlr/` miran; ninguno toca. Entra `wtype` (protocolo `zwp_virtual_keyboard_v1`) como
35INSTRUMENTO —no va en ninguna imagen— y con él se contesta el diálogo igual que lo haría una
36persona: flechas y `Enter`.
37
38⚠ **Y el render es por SOFTWARE a propósito, con `llvmpipe` y no con `softpipe`.** `shuma-pregunta`
39y `boveda` son llimphi/wgpu, o sea clientes de GL; el `mesa` de las imágenes es **iris-only** (Intel)
40y esta máquina tiene una virtio-gpu, así que EGL no encontraría driver. Se montan `mesa-llvmpipe` +
41`llvm18` como últimas capas. Con `mesa-swrast` (softpipe, GL 3.3) el diálogo **no abre**: wgpu pide
42compute shaders y muere con `RequestDevice("Parent device is lost")`, un mensaje que no nombra la
43causa. Es el entorno de prueba, no el artefacto: en la imagen de verdad el driver lo pone el metal.
44
45⚠⚠ **DÓNDE ESTÁ HOY (2026-09-18): la etapa A pasa y la B choca contra un muro que no es de la
46bóveda.** `boveda` y `shuma-pregunta` están sellados, y ninguno de los dos abre ventana: llimphi
47construye la instancia de wgpu **sin display handle** —su propio comentario lo dice: «este camino no
48tiene ventana todavía»—, wgpu cae entonces a la plataforma EGL **surfaceless**, que por definición no
49tiene configs de ventana, y `surface.get_capabilities()` devuelve **cero formatos**; llimphi hace
50`caps.formats[0]` y panica. En una máquina con Vulkan no se nota, porque el backend PRIMARY no
51necesita el display — y **ninguna imagen de takana tiene Vulkan**: las tres recetas de mesa se
52compilan con `-Dvulkan-drivers=` vacío. Ver SDD 26 §7.septies. Este guardián queda escrito y
53CORRIENDO hasta donde se puede: la A en verde es la medición del §7.sexies, vigilada.
54
55REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`) y, sellados,
56`atuq`, `puriy-costura`, `boveda`, `shuma-pregunta`, `wtype`, `llvm18` y `mesa-llvmpipe`.
57"""
58import argparse
59import http.server
60import json
61import os
62import shutil
63import socket
64import struct
65import subprocess
66import sys
67import tempfile
68import threading
69
70ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
71RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
72ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
73HAMMER = os.path.join(ROOT, "target/release/takana")
74STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
75
76# La raíz de la bóveda DE PRUEBA. Con esta variable puesta, la app deriva sus claves de esta cadena
77# en vez de la seed de identidad del llavero del kernel —que en ninguna máquina de desarrollo está
78# desbloqueada— y abre `…/tawasuyu/boveda-de-prueba`, que es OTRA bóveda, vacía, y lo dice en
79# pantalla. Sin esto este guardián no se podría encender en ningún lado.
80RAIZ_DE_PRUEBA = "metal-de-atuq"
81
82APPDIR = "usr/lib/atuq"
83
84USUARIO = "sergio"
85CLAVE = "Zx7#qLm2pVnR"
86TITULO = "Banco de prueba"
87
88ETAPAS = [
89 ("A", "sin la app `boveda`, el host contesta locked:true (el bug del §7.sexies, vigilado)"),
90 ("B", "con la app corriendo, vault.status dice ABIERTA: el dueño atiende el socket"),
91 ("C", "el DIÁLOGO abre en el compositor y se lo puede CONTESTAR (el instrumento: wtype)"),
92 ("D", "vault.save contestado que SÍ guarda, y vault.match la encuentra después"),
93 ("E", "el navegador real: vault.fill contestado que SÍ ⇒ la contraseña llega al campo"),
94 ("F", "vault.fill contestado que NO ⇒ denied y el campo queda VACÍO (el control de E)"),
95]
96
97# El atajo del diálogo: el dígito contesta con esa opción (`atajo_de_digito` en shuma-pregunta),
98# así que «1» es la primera opción y «2» la segunda. Se teclea con `wtype`, que es la única forma
99# que tiene este corpus de meter una tecla en un compositor.
100TECLA_SI = "1"
101TECLA_NO = "2"
102
103# La página DELATA lo que le pongan en el campo, y lo delata al SERVIDOR.
104#
105# Es la forma barata de medir el final de la cadena sin plomería chrome↔contenido: si la contraseña
106# llegó al formulario, el servidor la ve; si no llegó, no ve nada. Y mide lo que importa —el campo—
107# y no lo que el host contestó, que es la distinción que ya costó una pérdida de datos silenciosa en
108# este mismo frente (§7.quater: «el guardián leyó el DISCO, no la respuesta»).
109PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>Banco de prueba</title></head>
110<body><h1>Banco de prueba</h1>
111<form id="f" method="POST" action="/entrar">
112 <input id="u" name="username" type="text" autocomplete="username">
113 <input id="p" name="password" type="password" autocomplete="current-password">
114 <button id="b" type="submit">Entrar</button>
115</form>
116<script>
117(function () {
118 var avisado = false;
119 setInterval(function () {
120 var p = document.getElementById("p").value;
121 if (p && !avisado) {
122 avisado = true;
123 var x = new XMLHttpRequest();
124 x.open("POST", "/relleno", true);
125 x.setRequestHeader("Content-Type", "text/plain");
126 x.send(document.getElementById("u").value + "\\n" + p);
127 }
128 }, 300);
129})();
130</script>
131</body></html>
132"""
133
134
135
136# ── La sonda del chrome: quien aprieta el botón de la extensión ───────────────────────────────
137# Se APENDA al `atuq.cfg` del artefacto (no lo reemplaza), que es como lo hace `test-atuq-chrome.py`:
138# así se mide el fichero real de atuq con la sonda encima y no una copia que pudo divergir.
139#
140# ⚠ Aprieta el botón por la API interna (`browserActionFor(...).triggerAction`) y no por un click en
141# el DOM: desde las «extensiones unificadas» el botón vive en un panel que se construye recién al
142# abrirlo, así que un `getElementById(...).click()` no encuentra nada y eso se lee como «la bóveda no
143# ofreció». Si la API no estuviera, cae al DOM y DICE por cuál de los dos caminos fue — un camino que
144# no se nombra es un resultado que no se puede interpretar.
145SONDA_CHROME = """
146// ── SONDA DE PRUEBA — NO VIAJA EN EL ARTEFACTO (scripts/test-atuq-boveda-metal.py) ────────────
147try {
148 const S = (typeof Services !== "undefined")
149 ? Services
150 : ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs").Services;
151 const lineas = [];
152 const di = function (t) {
153 lineas.push(t);
154 try { dump("BOVEDA-METAL " + t + "\\n"); } catch (e) {}
155 try { IOUtils.writeUTF8("/salida/chrome.txt", lineas.join("\\n") + "\\n"); } catch (e) {}
156 };
157 di("VIVO");
158 S.obs.addObserver(function observador(win) {
159 S.obs.removeObserver(observador, "browser-delayed-startup-finished");
160 win.setTimeout(function () {
161 try {
162 // ⚠ `WebExtensionPolicy` NO es global en el scope de una ventana del chrome: medido, la
163 // sonda moría con «ReferenceError: WebExtensionPolicy is not defined» y eso se habría
164 // leído como «la extensión no está». Sale del módulo, que además lo reexporta.
165 const EP = ChromeUtils.importESModule(
166 "resource://gre/modules/ExtensionParent.sys.mjs").ExtensionParent;
167 const ext = EP.GlobalManager.getExtension("boveda@atuq.tawasuyu");
168 di("EXTENSION " + (ext ? "cargada" : "AUSENTE"));
169 if (!ext) { di("FIN"); return; }
170 let disparo = "ninguno";
171 try {
172 const ba = EP.apiManager.global.browserActionFor(ext);
173 if (ba) {
174 // ⚠ Lo que se mira ANTES de apretar, porque `triggerAction` no devuelve nada y un
175 // click que no llega se ve igual que una bóveda que no contesta:
176 // `triggerClickOrPopup` sale por la puerta de atrás —devuelve null y no dispara— si
177 // la acción NO está mostrada para esa pestaña (`isShownForTab`). Y la INSIGNIA dice
178 // si `vault.match` funcionó desde adentro del navegador, que es la otra mitad.
179 const t = win.gBrowser.selectedTab;
180 try { di("MOSTRADA " + ba.action.isShownForTab(t)); } catch (e) { di("MOSTRADA ? " + e); }
181 try { di("INSIGNIA " + JSON.stringify(ba.action.getProperty(t, "badgeText"))); }
182 catch (e) { di("INSIGNIA ? " + e); }
183 ba.triggerAction(win);
184 disparo = "api";
185 }
186 } catch (e) { di("API " + e); }
187 if (disparo === "ninguno") {
188 const b = win.document.getElementById("boveda_atuq_tawasuyu-browser-action");
189 if (b) { b.click(); disparo = "dom"; }
190 }
191 di("DISPARO " + disparo);
192 win.setTimeout(function () { di("FIN"); }, 8000);
193 } catch (e) {
194 di("EXCEPCION " + e);
195 }
196 }, ESPERA_MS);
197 }, "browser-delayed-startup-finished");
198} catch (e) {
199 try { dump("BOVEDA-METAL EXCEPCION-EXTERIOR " + e + "\\n"); } catch (e2) {}
200}
201"""
202
203
204def fatal(msg):
205 print(f"✗ {msg}", file=sys.stderr)
206 raise SystemExit(1)
207
208
209def artefacto(receta, nombre, escotilla=None):
210 """El artefacto VIGENTE, por `takana hash` y nunca por glob.
211
212 ⚠ La escotilla grita al usarse: el corpus es compartido y otro frente puede re-hashear una dep
213 dejando lo de acá sin sellar durante las horas de su build. Un resultado obtenido con la
214 escotilla NO se cita como «el de hoy pasa».
215 """
216 forzado = os.environ.get(escotilla) if escotilla else None
217 if forzado:
218 if not os.path.isdir(forzado):
219 fatal(f"{escotilla}={forzado} no existe")
220 print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
221 return forzado
222 r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
223 cwd=ROOT, capture_output=True, text=True)
224 if r.returncode != 0:
225 fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
226 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
227 d = os.path.join(STORE, f"{h}-{nombre}")
228 if not os.path.isdir(d):
229 fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes:\n"
230 f" flock -o work/.farm-build.lock {HAMMER} --store {STORE} build recipes/{receta}.toml")
231 return d
232
233
234class Servidor(http.server.SimpleHTTPRequestHandler):
235 """La página de login, servida de verdad. `atuq` la carga por la red como cualquier otra."""
236
237 def log_message(self, *a):
238 pass
239
240 def do_GET(self):
241 datos = PAGINA.encode()
242 self.send_response(200)
243 self.send_header("Content-Type", "text/html; charset=utf-8")
244 self.send_header("Content-Length", str(len(datos)))
245 self.send_header("Cache-Control", "no-store")
246 self.end_headers()
247 self.wfile.write(datos)
248
249 #: lo que la página delató que le pusieron en el campo, o None si nadie le puso nada.
250 relleno = None
251
252 def do_POST(self):
253 n = int(self.headers.get("Content-Length") or 0)
254 cuerpo = self.rfile.read(n)
255 if self.path.startswith("/relleno"):
256 Servidor.relleno = cuerpo.decode(errors="replace")
257 datos = b"<!doctype html><html><body>adentro</body></html>"
258 self.send_response(200)
259 self.send_header("Content-Type", "text/html; charset=utf-8")
260 self.send_header("Content-Length", str(len(datos)))
261 self.end_headers()
262 self.wfile.write(datos)
263
264
265def puerto_libre():
266 s = socket.socket()
267 s.bind(("0.0.0.0", 0))
268 p = s.getsockname()[1]
269 s.close()
270 return p
271
272
273# ── El cliente del cable, en Python ───────────────────────────────────────────────────────────
274# Marco de 4 bytes little-endian + JSON, que es lo que habla native messaging y lo que
275# `shared/foreign-webext` implementa del otro lado. Se escribe acá —y no se llama a la extensión—
276# porque las etapas A/B/C miden la CADENA host↔dueño↔diálogo, donde el navegador no participa:
277# meterlo sólo agregaría diez minutos y un sospechoso más a cada fallo.
278CLIENTE = r'''
279import json, os, struct, subprocess, sys
280def hablar(binario, estado, mensajes):
281 p = subprocess.Popen([binario, "--state", estado],
282 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
283 entrada = b""
284 for m in mensajes:
285 b = json.dumps(m).encode()
286 entrada += struct.pack("<I", len(b)) + b
287 fuera, err = p.communicate(entrada, timeout=180)
288 salida, i = [], 0
289 while i + 4 <= len(fuera):
290 n = struct.unpack("<I", fuera[i:i+4])[0]; i += 4
291 salida.append(json.loads(fuera[i:i+n])); i += n
292 return salida, err.decode(errors="replace")
293'''
294
295
296
297# ── El contestador: quien dice que sí (o que no) donde diría una persona ──────────────────────
298# ⚠ Espera a que la ventana esté en el ÁRBOL DEL COMPOSITOR y con el foco, **no** a que el proceso
299# exista. Que `shuma-pregunta` esté corriendo no dice que su superficie esté mapeada ni enfocada, y
300# una tecla mandada antes no la recibe nadie: el diálogo se queda abierto y el resultado se lee como
301# «el consentimiento no contesta», que es la conclusión contraria a la verdadera. El compositor es
302# el único que sabe quién tiene el foco, así que se le pregunta a él.
303CONTESTADOR = r"""
304import json, re, subprocess, sys, time
305
306TECLA = sys.argv[1]
307ESPERA = int(sys.argv[2]) if len(sys.argv) > 2 else 120
308# ⚠ El patrón NO es opcional cuando hay más de una ventana, y la razón es el único modo de fallo
309# peligroso de este arnés: con el navegador abierto, contestar «la ventana enfocada» le teclearía el
310# «sí» A LA PÁGINA, y el diálogo se quedaría esperando. El resultado sería «el usuario no contestó»
311# —o peor, un sí que nadie dio— y las dos cosas se leen como un producto que no anda.
312PATRON = re.compile(sys.argv[3], re.I) if len(sys.argv) > 3 else None
313
314
315def arbol():
316 r = subprocess.run(["/usr/bin/swaymsg", "-t", "get_tree", "-r"],
317 capture_output=True, text=True)
318 if r.returncode != 0 or not r.stdout.strip():
319 return None
320 try:
321 return json.loads(r.stdout)
322 except Exception:
323 return None
324
325
326def ventanas(n, acc=None):
327 acc = [] if acc is None else acc
328 if n.get("pid") and n.get("type") in ("con", "floating_con"):
329 acc.append(n)
330 for hijo in (n.get("nodes") or []) + (n.get("floating_nodes") or []):
331 ventanas(hijo, acc)
332 return acc
333
334
335def pega(v):
336 if PATRON is None:
337 return True
338 return bool(PATRON.search((v.get("app_id") or "") + " " + (v.get("name") or "")))
339
340
341t0 = time.time()
342visto = None
343while time.time() - t0 < ESPERA:
344 a = arbol()
345 if a:
346 vs = ventanas(a)
347 if vs:
348 visto = vs
349 print("VENTANAS " + json.dumps(
350 [{"app_id": v.get("app_id"), "name": v.get("name"),
351 "focused": bool(v.get("focused")),
352 "rect": v.get("rect")} for v in vs], ensure_ascii=False))
353 candidatas = [v for v in vs if pega(v)]
354 if candidatas:
355 objetivo = candidatas[0]
356 if not objetivo.get("focused"):
357 # Se le PIDE el foco al compositor en vez de esperar a que lo tenga: con el
358 # navegador abierto el diálogo puede salir detrás, y una tecla al aire no
359 # se distingue de un diálogo que no contesta.
360 con = objetivo.get("app_id") or objetivo.get("name") or ""
361 llave = "app_id" if objetivo.get("app_id") else "title"
362 subprocess.run(["/usr/bin/swaymsg", '[%s="%s"] focus' % (llave, con)],
363 capture_output=True, text=True)
364 print("FOCO pedido para %s=%s" % (llave, con))
365 time.sleep(1)
366 # ⚠ SE INSISTE hasta que la ventana se vaya, y no se teclea una sola vez.
367 # Medido: con el mismo diálogo y el mismo `wtype rc=0`, una corrida contestaba y
368 # otra volvía `denied`. La ventana aparece en el árbol del compositor ANTES de que
369 # llimphi tenga su teclado enganchado —bajo llvmpipe el primer frame tarda—, así
370 # que la primera tecla puede caer en el vacío. Un `sleep` más largo sólo mueve el
371 # borde; lo que cierra el caso es mirar si la ventana SIGUE ahí. Teclear de más es
372 # inofensivo: si el diálogo ya cerró, el dígito va a una página web.
373 for intento in range(12):
374 time.sleep(2)
375 r = subprocess.run(["/usr/bin/wtype", TECLA], capture_output=True, text=True)
376 print("WTYPE %s intento=%s rc=%s %s"
377 % (TECLA, intento + 1, r.returncode, r.stderr.strip()))
378 time.sleep(1)
379 a2 = arbol()
380 if a2 is None or not [v for v in ventanas(a2) if pega(v)]:
381 print("CERRO tras %s intento(s)" % (intento + 1))
382 raise SystemExit(0)
383 print("SIGUE ABIERTO tras 12 intentos: la tecla no lo cierra")
384 raise SystemExit(1)
385 time.sleep(1)
386print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000]))
387raise SystemExit(1)
388"""
389
390
391def guion_dentro(cuerpo, con_boveda, log="pasos.log", salida_rel="/salida"):
392 """El guión que corre DENTRO de la jaula, con sway headless levantado.
393
394 El compositor se levanta siempre —aunque la etapa no lo necesite— porque el diálogo es un
395 cliente wayland y `boveda` también: sin compositor no se distingue «la app no abrió la bóveda»
396 de «la app no encontró dónde pintar», que son dos causas muy distintas con el mismo síntoma.
397 """
398 arranque_boveda = ""
399 if con_boveda:
400 arranque_boveda = f"""
401 # El DUEÑO de la bóveda. Se lanza con la raíz de prueba puesta, así que abre
402 # `…/tawasuyu/boveda-de-prueba` y no la de nadie. Levanta el socket del navegador en un hilo.
403 BOVEDA_TEST_ROOT={RAIZ_DE_PRUEBA} /usr/bin/boveda >> {salida_rel}/boveda.log 2>&1 &
404 echo "@@ boveda lanzada pid $!" >> {salida_rel}/{log}
405 # Se espera al SOCKET, no a un `sleep`: un sleep que alcanza hoy no alcanza en una máquina
406 # cargada, y el fallo aparece como «la bóveda estaba cerrada» — la conclusión contraria.
407 i=0
408 while [ ! -S "$XDG_RUNTIME_DIR/boveda.sock" ] && [ $i -lt 60 ]; do sleep 1; i=$((i+1)); done
409 [ -S "$XDG_RUNTIME_DIR/boveda.sock" ] \\
410 && echo "@@ socket de la boveda LISTO en ${{i}}s" >> {salida_rel}/{log} \\
411 || echo "@@ SIN socket de la boveda tras ${{i}}s" >> {salida_rel}/{log}
412 # ⚠ Y se espera además a que la app PINTE, no sólo a que atienda. El socket lo levanta un hilo
413 # antes de que haya ventana, así que seguir acá deja a la app inicializando su GPU al mismo
414 # tiempo que el diálogo inicializa la suya — y con render por software en cuatro núcleos eso se
415 # paga en el diálogo: aparece en el árbol del compositor, toma el foco y **no responde al
416 # teclado**. Medido: doce teclas seguidas sin efecto y `vault.save` volviendo `denied` por
417 # timeout, con la ventana todavía abierta.
418 j=0
419 while [ $j -lt 90 ]; do
420 /usr/bin/swaymsg -t get_tree -r 2>/dev/null | grep -q '"app_id": *"boveda"' && break
421 sleep 1; j=$((j+1))
422 done
423 echo "@@ ventana de la boveda a los ${{j}}s" >> {salida_rel}/{log}
424"""
425 return f"""#!/bin/sh
426export XDG_RUNTIME_DIR=/run/user/0 HOME={salida_rel}/hogar LANG=C XDG_DATA_DIRS=/usr/share
427export XDG_STATE_HOME={salida_rel}/hogar/.local/state
428export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
429# El cliente de GL va por software: ver la cabecera. `LIBGL_ALWAYS_SOFTWARE` hace que EGL elija
430# `swrast` en vez de buscar un driver de la GPU que esta máquina no tiene, y `GALLIUM_DRIVER`
431# elige CUÁL de los dos software.
432#
433# ⚠ **Y tiene que ser `llvmpipe`, no `softpipe`, y no es una preferencia.** Medido el 2026-09-18:
434# con softpipe el diálogo no abre nunca y muere con `RequestDevice("Parent device is lost")`, que no
435# nombra la causa. La causa está una línea más arriba en el log, y sólo aparece con
436# `RUST_LOG=wgpu_core=info`:
437#
438# Renderer: softpipe · Version: 3.3 (Core Profile)
439# indirect-validation error: ComputePipeline(Internal("The selected version doesn't support
440# Features(BUFFER_STORAGE | COMPUTE_SHADER | DYNAMIC_ARRAY_SIZE)"))
441#
442# O sea: **wgpu exige compute shaders** y softpipe se queda en GL 3.3. `llvmpipe` da GL 4.5 y pasa.
443# Las dos son «mesa por software» y en el store son dos recetas hermanas (`mesa-swrast` es softpipe,
444# `mesa-llvmpipe` es llvmpipe + `llvm18`), así que la diferencia se ve como un detalle de nombre y
445# decide si el arnés existe o no.
446export LIBGL_ALWAYS_SOFTWARE=1 LIBGL_DRIVERS_PATH=/usr/lib/dri GALLIUM_DRIVER=llvmpipe
447mkdir -p "$XDG_RUNTIME_DIR" {salida_rel}/hogar "$XDG_STATE_HOME"; chmod 700 "$XDG_RUNTIME_DIR"
448(
449 i=0
450 while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
451 [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland" >> {salida_rel}/{log}; exit 1; }}
452 export WAYLAND_DISPLAY=wayland-1
453 export SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
454 echo "@@ wayland listo" >> {salida_rel}/{log}
455{arranque_boveda}
456{cuerpo}
457 sleep 1
458 /usr/bin/swaymsg exit 2>/dev/null
459) &
460exec /usr/bin/sway -d > {salida_rel}/sway.log 2>&1
461"""
462
463
464def correr_jaula(capas, salida, guion, segundos=420):
465 """Una corrida de bwrap con las capas dadas. La ÚLTIMA capa gana."""
466 ruta = os.path.join(salida, "dentro.sh")
467 with open(ruta, "w") as fh:
468 fh.write(guion)
469 orden = ["bwrap"]
470 for c in capas:
471 orden += ["--overlay-src", c]
472 orden += [
473 "--tmp-overlay", "/",
474 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
475 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
476 "--bind", salida, "/salida",
477 "--uid", "0", "--gid", "0",
478 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
479 "/bin/sh", "/salida/dentro.sh",
480 ]
481 try:
482 subprocess.run(orden, check=False, timeout=segundos)
483 return True
484 except subprocess.TimeoutExpired:
485 print(f" ⚠ la jaula no se cerró sola; se la cortó a los {segundos} s")
486 return False
487
488
489def leer(salida, nombre):
490 r = os.path.join(salida, nombre)
491 return open(r, errors="replace").read() if os.path.exists(r) else ""
492
493
494def main():
495 ap = argparse.ArgumentParser(add_help=False)
496 ap.add_argument("--hasta", default="F")
497 ap.add_argument("--list", action="store_true")
498 ap.add_argument("-h", "--help", action="store_true")
499 args, _ = ap.parse_known_args()
500 if args.help:
501 print(__doc__)
502 return
503 if args.list:
504 for clave, que in ETAPAS:
505 print(f" {clave} {que}")
506 return
507
508 hasta = args.hasta.upper()
509 if hasta not in [c for c, _ in ETAPAS]:
510 fatal(f"--hasta {hasta}: las etapas son " + ", ".join(c for c, _ in ETAPAS))
511
512 if not os.path.isdir(RFS):
513 fatal(f"no está el rootfs de sway en {RFS} — hidratalo con "
514 "`scripts/hydrate-profile.py escritorio-sway --into …`")
515
516 # Los artefactos se exigen POR ETAPA y no todos de entrada. No es comodidad: pedir los seis para
517 # correr la A haría que este guardián no se pudiera encender mientras se construye una de las
518 # piezas que él mismo destrabó —y la A es justamente la que mide el estado SIN esas piezas—.
519 necesita = {"A": [("puriy-costura", "puriy-costura", None)]}
520 necesita["B"] = necesita["A"] + [("boveda", "boveda", None),
521 ("llvm18", "llvm18", None),
522 ("mesa-llvmpipe", "mesa-llvmpipe", None)]
523 necesita["C"] = necesita["B"] + [("shuma-pregunta", "shuma-pregunta", None), ("wtype", "wtype", None)]
524 necesita["D"] = necesita["C"] + [("atuq", "atuq", "ATUQ_DIR")]
525 necesita["E"] = necesita["D"]
526 necesita["F"] = necesita["D"]
527
528 dirs = {}
529 for receta, nombre, escotilla in necesita[hasta]:
530 dirs[nombre] = artefacto(receta, nombre, escotilla)
531 print(f"artefacto: {os.path.basename(dirs[nombre])}")
532
533 # El orden de las capas ES la semántica: **la última gana**. El mesa por software va al final
534 # para pisar el `iris`-only del rootfs; si fuera antes, el rootfs le devolvería el driver de una
535 # GPU Intel que esta máquina no tiene. `llvm18` va antes porque `swrast_dri.so` lo NECESITA:
536 # sin él, el loader dice «failed to open swrast: libLLVM.so.18.1» y wgpu reporta «NoAdapter»,
537 # que es el mismo síntoma que no tener driver.
538 capas_base = [ALPINE, RFS]
539 for n in ("atuq", "puriy-costura", "boveda", "shuma-pregunta", "wtype", "llvm18", "mesa-llvmpipe"):
540 if n in dirs:
541 capas_base.append(dirs[n])
542
543 # El servidor de la página de login. Se levanta aunque la etapa no lo use: su PUERTO es lo que
544 # define la URL con la que se guarda la credencial, y esa URL tiene que ser la MISMA en la
545 # etapa que guarda y en la que el navegador visita — si no, `vault.match` no encontraría nada
546 # y el fallo hablaría de la bóveda cuando el que cambió fue el puerto.
547 puerto = puerto_libre()
548 servidor = http.server.ThreadingHTTPServer(("0.0.0.0", puerto), Servidor)
549 threading.Thread(target=servidor.serve_forever, daemon=True).start()
550 url = f"http://127.0.0.1:{puerto}/"
551 print(f"la página de login se sirve en {url}")
552
553 tmp = tempfile.mkdtemp(prefix="atuq-boveda-metal-")
554 salida = os.path.join(tmp, "salida")
555 os.makedirs(salida)
556 fallos = []
557 try:
558 correr_etapas(capas_base, dirs, salida, hasta, url, fallos)
559 finally:
560 guardado = os.path.join(ROOT, "work", "atuq-boveda-metal")
561 os.makedirs(guardado, exist_ok=True)
562 for n in os.listdir(salida):
563 o = os.path.join(salida, n)
564 if os.path.isfile(o):
565 shutil.copy(o, os.path.join(guardado, n))
566 print(f"\nlogs en {guardado}")
567 shutil.rmtree(tmp, ignore_errors=True)
568
569 if fallos:
570 for f in fallos:
571 print(f"✗ {f}", file=sys.stderr)
572 raise SystemExit(1)
573 print("\n✓ todo lo que se corrió, en verde")
574
575
576def respuesta(salida, fichero, verbo):
577 """La respuesta a un verbo, leída del JSON que dejó el preguntador dentro de la jaula."""
578 crudo = leer(salida, fichero)
579 if not crudo.strip():
580 return None
581 for r in json.loads(crudo):
582 if r.get("verb") == verbo:
583 return r
584 return None
585
586
587def correr_etapas(capas, dirs, salida, hasta, url, fallos):
588 """Las etapas, en orden y cada una en su propia jaula.
589
590 Cada etapa levanta su propio compositor. Es más lento que reusar uno y es a propósito: el
591 estado que importa —la bóveda de prueba, el registro del host— vive en `/salida`, que se
592 comparte, y así una etapa no hereda procesos de la anterior. Dos corridas sobre el mismo
593 log se MEZCLAN y el resultado parece un pase; eso ya pasó en este frente (§6.1.bis).
594 """
595 orden = [c for c, _ in ETAPAS]
596 tope = orden.index(hasta)
597 escribir_preguntador(salida, url)
598 with open(os.path.join(salida, "contestar.py"), "w") as fh:
599 fh.write(CONTESTADOR)
600
601 # ── A: SIN la app. Es el estado en que estuvo el corpus entero hasta el 2026-09-18. ────────
602 print("\n== A · sin la app `boveda`, ¿qué contesta el host?")
603 cuerpo = " python3 /salida/preguntar.py sin-boveda >> /salida/pasos-A.log 2>&1\n"
604 correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-A.log"), segundos=240)
605 print(indentar(leer(salida, "pasos-A.log")))
606 estado = respuesta(salida, "sin-boveda.json", "vault.status")
607 if estado is None:
608 fallos.append("A: el preguntador no dejó respuesta — mirá pasos-A.log")
609 elif estado.get("locked") is not True:
610 fallos.append(f"A: sin la app, vault.status tenía que decir locked:true y dijo {estado}")
611 else:
612 print(" ✓ A: sin dueño, la bóveda contesta CERRADA (y con ok:true, que es la trampa)")
613 if tope < orden.index("B"):
614 return
615
616 # ── B: CON la app. El dueño abre la base y atiende el socket. ──────────────────────────────
617 print("\n== B · con la app `boveda` corriendo, ¿la bóveda está abierta?")
618 cuerpo = " python3 /salida/preguntar.py con-boveda >> /salida/pasos-B.log 2>&1\n"
619 correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-B.log"), segundos=300)
620 print(indentar(leer(salida, "pasos-B.log")))
621 estado = respuesta(salida, "con-boveda.json", "vault.status")
622 if estado is None:
623 fallos.append("B: el preguntador no dejó respuesta — mirá pasos-B.log y boveda.log")
624 elif estado.get("locked") is not False:
625 fallos.append(f"B: con la app corriendo, vault.status tenía que decir locked:false y dijo "
626 f"{estado}.\n"
627 " Mirá `boveda.log`. Si dice «index out of bounds: the len is 0 but the index "
628 "is 0» en `llimphi-hal/src/lib.rs`, NO es de la bóveda: es el muro del §7.septies "
629 "—llimphi construye la instancia de wgpu SIN display handle, wgpu cae a la "
630 "plataforma EGL surfaceless, esa plataforma no tiene configs de ventana, y la "
631 "lista de formatos de la surface sale vacía—. Se destraba en llimphi, no acá, y "
632 "hasta entonces la app no abre ventana ni levanta el socket del navegador.")
633 else:
634 print(" ✓ B: el dueño atiende — la bóveda de prueba está ABIERTA")
635 if tope < orden.index("C"):
636 return
637
638 # ── C: el instrumento. Antes de pedirle nada a la bóveda, ¿se puede contestar un diálogo? ──
639 # Va ANTES de D a propósito: si el diálogo no se puede contestar, `vault.save` va a decir
640 # `denied` — que es la respuesta CORRECTA a un consentimiento que no llegó— y sin esta etapa
641 # eso se leería como «la bóveda no guarda». Separar el instrumento del producto es lo que
642 # hace que el fallo diga cuál de los dos se rompió.
643 print("\n== C · ¿abre el diálogo en el compositor, y se lo puede contestar?")
644 cuerpo = """
645 for par in si:%s:1 no:%s:2; do
646 quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:}
647 ( python3 /salida/contestar.py "$tecla" 120 "pregunta|shuma" > /salida/contestador-C-$quien.log 2>&1 ) &
648 contestador=$!
649 /usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \
650 --option "yes=Si, contesta" --option "no=No" \
651 > /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err
652 echo "@@ dialogo $quien salio con $? y dijo: $(cat /salida/dialogo-C-$quien.json)" >> /salida/pasos-C.log
653 wait "$contestador"
654 done
655""" % (TECLA_SI, TECLA_NO)
656 correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-C.log"), segundos=420)
657 print(indentar(leer(salida, "pasos-C.log")))
658 for quien in ("si", "no"):
659 print(indentar(leer(salida, f"contestador-C-{quien}.log")))
660 dijo_si = leer(salida, "dialogo-C-si.json").strip()
661 dijo_no = leer(salida, "dialogo-C-no.json").strip()
662 if '"choice":"yes"' not in dijo_si.replace(" ", ""):
663 fallos.append(f"C: al teclear «{TECLA_SI}» el diálogo tenía que contestar yes y dijo {dijo_si!r} "
664 "— si no hay VENTANAS en el log del contestador, el cliente de GL no pintó "
665 "(mirá dialogo-C-si.err: es donde llimphi dice por qué)")
666 elif '"choice":"no"' not in dijo_no.replace(" ", ""):
667 # El control: sin él, un diálogo que contestara «yes» pase lo que pase se vería igual.
668 fallos.append(f"C: el control falló — al teclear «{TECLA_NO}» tenía que contestar no y dijo {dijo_no!r}")
669 else:
670 print(" ✓ C: el diálogo abre, la tecla llega, y contesta lo que se le tecleó (sí Y no)")
671 if tope < orden.index("D"):
672 return
673
674 # ── D: la bóveda guarda, y sólo con permiso. ───────────────────────────────────────────────
675 print("\n== D · vault.save con el consentimiento dado, ¿guarda? ¿y después la encuentra?")
676 cuerpo = """
677 ( python3 /salida/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-D.log 2>&1 ) &
678 contestador=$!
679 python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1
680 # ⚠ `wait "$contestador"` y NO `wait` a secas: la app de la bóveda TAMBIÉN es un trabajo en
681 # segundo plano, así que un `wait` pelado no vuelve nunca, la jaula se come su timeout y a la
682 # app la mata un KILL. Y lo que se pierde ahí es lo que se acaba de guardar: `sled` no alcanza a
683 # volcar, la etapa siguiente no encuentra la credencial, y el cuadro que queda es «la bóveda no
684 # guardó» — el diagnóstico contrario al verdadero.
685 wait "$contestador"
686""" % TECLA_SI
687 correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-D.log"), segundos=420)
688 print(indentar(leer(salida, "pasos-D.log")))
689 print(indentar(leer(salida, "contestador-D.log")))
690 guardo = respuesta(salida, "guardar.json", "vault.save")
691 encontro = respuesta(salida, "guardar.json", "vault.match")
692 if guardo is None:
693 fallos.append("D: el preguntador no dejó respuesta de vault.save — mirá pasos-D.log")
694 elif guardo.get("denied") is True:
695 fallos.append("D: vault.save salió DENEGADO. Si la etapa C está en verde, el diálogo se puede "
696 "contestar y esto es del producto; si C falló, es el instrumento y no la bóveda")
697 elif not guardo.get("item"):
698 fallos.append(f"D: vault.save no devolvió el identificador de lo guardado: {guardo}")
699 elif not (encontro or {}).get("items"):
700 fallos.append(f"D: guardó pero vault.match no la encuentra para {url}: {encontro}")
701 else:
702 item = encontro["items"][0]
703 if item.get("username") != USUARIO:
704 fallos.append(f"D: la credencial encontrada no es la guardada: {item}")
705 else:
706 print(f" ✓ D: guardada con permiso y encontrada de nuevo ({item.get('title')!r}, "
707 f"usuario {item.get('username')!r})")
708 # Y la mitad que importa: `vault.match` NO puede traer la contraseña. El tipo del
709 # otro lado no tiene dónde ponerla, y esto lo comprueba desde afuera.
710 if any(k in item for k in ("password", "clave")):
711 fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}")
712
713 if tope < orden.index("E"):
714 return
715
716 # ── E y F: el navegador de verdad, y el par que hace que E signifique algo. ────────────────
717 # La E sola no prueba nada: una bóveda que entrega SIEMPRE se ve idéntica a una que entrega con
718 # permiso. Lo único que las separa es contestar que NO y ver que no llega nada.
719 for etapa, tecla, espera_relleno in (("E", TECLA_SI, True), ("F", TECLA_NO, False)):
720 if tope < orden.index(etapa):
721 return
722 que = "SÍ" if espera_relleno else "NO"
723 print(f"\n== {etapa} · el navegador pide la contraseña y se contesta que {que}")
724 Servidor.relleno = None
725 correr_navegador(capas, dirs, salida, url, tecla, etapa)
726 print(indentar(leer(salida, f"pasos-{etapa}.log")))
727 print(indentar(leer(salida, f"contestador-{etapa}.log")))
728 print(indentar(leer(salida, "chrome.txt")))
729 lineas = [l for l in leer(salida, f"atuq-{etapa}.log").splitlines() if l.startswith("BOVEDA ")]
730 for l in lineas[:12]:
731 print(" " + l)
732 llego = Servidor.relleno
733 if etapa == "E":
734 if "CONECTADO" not in " ".join(lineas):
735 fallos.append("E: la extensión de la bóveda no llegó a conectar con el host "
736 "(no hay línea «BOVEDA CONECTADO») — mirá atuq-E.log")
737 if llego is None:
738 fallos.append("E: la contraseña NUNCA llegó al campo. Si el contestador no vio la "
739 "ventana del diálogo, el muro es el del §7.septies; si la vio y tecleó, "
740 "mirá si el DISPARO del chrome fue «ninguno» (nadie apretó el botón)")
741 elif CLAVE not in llego:
742 fallos.append(f"E: al campo llegó algo que no es la contraseña guardada: {llego!r}")
743 else:
744 print(" ✓ E: la contraseña llegó al formulario, y sólo después del sí")
745 else:
746 if llego is not None:
747 fallos.append(f"F: ⚠⚠ contestando que NO igual llegó algo al campo: {llego!r}. "
748 "Es la falla que este guardián existe para atrapar: la bóveda entrega "
749 "sin permiso")
750 else:
751 print(" ✓ F: con el no, al campo no llegó nada (y es el control que sostiene la E)")
752
753
754def correr_navegador(capas, dirs, salida, url, tecla, etapa):
755 """Una corrida con `atuq` de verdad: la página servida, el botón de la extensión, el diálogo.
756
757 La sonda del chrome se apenda al `atuq.cfg` del artefacto en una capa propia — se mide el
758 fichero real con la sonda encima, no una copia.
759 """
760 capa = os.path.join(salida, f"capa-{etapa}")
761 app = os.path.join(capa, APPDIR)
762 os.makedirs(app, exist_ok=True)
763 original = open(os.path.join(dirs["atuq"], APPDIR, "atuq.cfg"), encoding="utf8").read()
764 with open(os.path.join(app, "atuq.cfg"), "w", encoding="utf8") as fh:
765 fh.write(original)
766 # La espera antes de apretar: la extensión tiene que haber conectado con el host y haber
767 # mirado la pestaña. Apretar antes es preguntarle a una bóveda que todavía no se enteró.
768 fh.write(SONDA_CHROME.replace("ESPERA_MS", "9000"))
769 perfil = os.path.join(salida, f"perfil-{etapa}")
770 os.makedirs(perfil, exist_ok=True)
771 with open(os.path.join(perfil, "user.js"), "w") as fh:
772 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
773 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
774 cuerpo = """
775 # El patrón del contestador NO es opcional acá: con el navegador abierto, teclear «la ventana
776 # enfocada» le contestaría a la PÁGINA. Se busca la ventana del diálogo por app_id/título.
777 ( python3 /salida/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-%s.log 2>&1 ) &
778 contestador=$!
779 export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland
780 # Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
781 # con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
782 export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
783 timeout 150 /usr/bin/atuq --no-remote --profile /salida/perfil-%s '%s' >> /salida/atuq-%s.log 2>&1
784 echo "@@ atuq salio con $?" >> /salida/pasos-%s.log
785 wait "$contestador"
786""" % (tecla, etapa, etapa, url, etapa, etapa)
787 correr_jaula(capas + [capa], salida, guion_dentro(cuerpo, con_boveda=True, log=f"pasos-{etapa}.log"),
788 segundos=420)
789
790
791def indentar(t):
792 return "\n".join(" " + l for l in t.splitlines() if l.strip())
793
794
795def escribir_preguntador(salida, url):
796 """El cliente del cable que corre DENTRO de la jaula.
797
798 Habla con el host EXACTAMENTE como lo hace la extensión —marco de 4 bytes + JSON— y no con la
799 bóveda directamente: lo que este guardián tiene que medir es la cadena entera, y el host es el
800 único que sabe encontrar al dueño.
801 """
802 with open(os.path.join(salida, "preguntar.py"), "w") as fh:
803 fh.write(CLIENTE)
804 fh.write(f'''
805QUE = sys.argv[1]
806URL = {url!r}
807BIN = "/usr/bin/puriy-costura"
808ESTADO = "/salida/hogar/estado-costura"
809os.makedirs(ESTADO, exist_ok=True)
810# `ping` va SIEMPRE y es el control de la corrida: separa «el host no contestó» de «el host
811# contestó que la bóveda está cerrada», que es la distinción que costó tres semanas.
812mensajes = [
813 {{"id": 1, "verb": "ping"}},
814 {{"id": 2, "verb": "vault.status"}},
815]
816if QUE == "guardar":
817 mensajes.append({{"id": 3, "verb": "vault.save", "url": URL,
818 "username": {USUARIO!r}, "password": {CLAVE!r}, "title": {TITULO!r}}})
819if QUE in ("match", "guardar"):
820 mensajes.append({{"id": 4, "verb": "vault.match", "url": URL}})
821respuestas, err = hablar(BIN, ESTADO, mensajes)
822open("/salida/%s.json" % QUE, "w").write(json.dumps(respuestas, ensure_ascii=False, indent=1))
823open("/salida/%s.err" % QUE, "w").write(err)
824for r in respuestas:
825 print(" <-", json.dumps(r, ensure_ascii=False))
826''')
827
828
829if __name__ == "__main__":
830 main()