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 1181Shell 82Rust 62Markdown 521515 Definitions

⎇ e0e390c2829a2bb6d30b49193dc5ced05bd45ad2 scripts/test-atuq-boveda-metal.py Python · 830 lines · 42.9 KB History Who touched what raw

atuq: la bóveda estaba SELLADA y en ninguna imagen — y estar en la imagen no es poder abrirla e6ab5a501 · Sergio ·

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