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