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 1518Shell 194Rust 108Markdown 983677 Definitions

⎇ c7c233755906f41d36c319886430edc8459185ee scripts/test-atuq-boveda-metal.py Python · 827 lines · 42.6 KB History Who touched what raw

atuq: la bóveda ENTREGA una contraseña, y sólo cuando alguien dice que sí — las seis etapas en verde 4be468031 · 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 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  # ⚠ Y se espera además a que la app PINTE, no sólo a que atienda. El socket lo levanta un hilo
410  # antes de que haya ventana, así que seguir acá deja a la app inicializando su GPU al mismo
411  # tiempo que el diálogo inicializa la suya — y con render por software en cuatro núcleos eso se
412  # paga en el diálogo: aparece en el árbol del compositor, toma el foco y **no responde al
413  # teclado**. Medido: doce teclas seguidas sin efecto y `vault.save` volviendo `denied` por
414  # timeout, con la ventana todavía abierta.
415  j=0
416  while [ $j -lt 90 ]; do
417    /usr/bin/swaymsg -t get_tree -r 2>/dev/null | grep -q '"app_id": *"boveda"' && break
418    sleep 1; j=$((j+1))
419  done
420  echo "@@ ventana de la boveda a los ${{j}}s" >> {salida_rel}/{log}
421"""
422    return f"""#!/bin/sh
423export XDG_RUNTIME_DIR=/run/user/0 HOME={salida_rel}/hogar LANG=C XDG_DATA_DIRS=/usr/share
424export XDG_STATE_HOME={salida_rel}/hogar/.local/state
425export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
426# El cliente de GL va por software: ver la cabecera. `LIBGL_ALWAYS_SOFTWARE` hace que EGL elija
427# `swrast` en vez de buscar un driver de la GPU que esta máquina no tiene, y `GALLIUM_DRIVER`
428# elige CUÁL de los dos software.
429#
430# ⚠ **Y tiene que ser `llvmpipe`, no `softpipe`, y no es una preferencia.** Medido el 2026-09-18:
431# con softpipe el diálogo no abre nunca y muere con `RequestDevice("Parent device is lost")`, que no
432# nombra la causa. La causa está una línea más arriba en el log, y sólo aparece con
433# `RUST_LOG=wgpu_core=info`:
434#
435#     Renderer: softpipe · Version: 3.3 (Core Profile)
436#     indirect-validation error: ComputePipeline(Internal("The selected version doesn't support
437#         Features(BUFFER_STORAGE | COMPUTE_SHADER | DYNAMIC_ARRAY_SIZE)"))
438#
439# O sea: **wgpu exige compute shaders** y softpipe se queda en GL 3.3. `llvmpipe` da GL 4.5 y pasa.
440# Las dos son «mesa por software» y en el store son dos recetas hermanas (`mesa-swrast` es softpipe,
441# `mesa-llvmpipe` es llvmpipe + `llvm18`), así que la diferencia se ve como un detalle de nombre y
442# decide si el arnés existe o no.
443export LIBGL_ALWAYS_SOFTWARE=1 LIBGL_DRIVERS_PATH=/usr/lib/dri GALLIUM_DRIVER=llvmpipe
444mkdir -p "$XDG_RUNTIME_DIR" {salida_rel}/hogar "$XDG_STATE_HOME"; chmod 700 "$XDG_RUNTIME_DIR"
445(
446  i=0
447  while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
448  [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland" >> {salida_rel}/{log}; exit 1; }}
449  export WAYLAND_DISPLAY=wayland-1
450  export SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
451  echo "@@ wayland listo" >> {salida_rel}/{log}
452{arranque_boveda}
453{cuerpo}
454  sleep 1
455  /usr/bin/swaymsg exit 2>/dev/null
456) &
457exec /usr/bin/sway -d > {salida_rel}/sway.log 2>&1
458"""
459
460
461def correr_jaula(capas, salida, guion, segundos=420):
462    """Una corrida de bwrap con las capas dadas. La ÚLTIMA capa gana."""
463    ruta = os.path.join(salida, "dentro.sh")
464    with open(ruta, "w") as fh:
465        fh.write(guion)
466    orden = ["bwrap"]
467    for c in capas:
468        orden += ["--overlay-src", c]
469    orden += [
470        "--tmp-overlay", "/",
471        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
472        "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
473        "--bind", salida, "/salida",
474        "--uid", "0", "--gid", "0",
475        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
476        "/bin/sh", "/salida/dentro.sh",
477    ]
478    try:
479        subprocess.run(orden, check=False, timeout=segundos)
480        return True
481    except subprocess.TimeoutExpired:
482        print(f"   ⚠ la jaula no se cerró sola; se la cortó a los {segundos} s")
483        return False
484
485
486def leer(salida, nombre):
487    r = os.path.join(salida, nombre)
488    return open(r, errors="replace").read() if os.path.exists(r) else ""
489
490
491def main():
492    ap = argparse.ArgumentParser(add_help=False)
493    ap.add_argument("--hasta", default="F")
494    ap.add_argument("--list", action="store_true")
495    ap.add_argument("-h", "--help", action="store_true")
496    args, _ = ap.parse_known_args()
497    if args.help:
498        print(__doc__)
499        return
500    if args.list:
501        for clave, que in ETAPAS:
502            print(f"  {clave}  {que}")
503        return
504
505    hasta = args.hasta.upper()
506    if hasta not in [c for c, _ in ETAPAS]:
507        fatal(f"--hasta {hasta}: las etapas son " + ", ".join(c for c, _ in ETAPAS))
508
509    if not os.path.isdir(RFS):
510        fatal(f"no está el rootfs de sway en {RFS} — hidratalo con "
511              "`scripts/hydrate-profile.py escritorio-sway --into …`")
512
513    # Los artefactos se exigen POR ETAPA y no todos de entrada. No es comodidad: pedir los seis para
514    # correr la A haría que este guardián no se pudiera encender mientras se construye una de las
515    # piezas que él mismo destrabó —y la A es justamente la que mide el estado SIN esas piezas—.
516    necesita = {"A": [("puriy-costura", "puriy-costura", None)]}
517    necesita["B"] = necesita["A"] + [("boveda", "boveda", None),
518                                 ("llvm18", "llvm18", None),
519                                 ("mesa-llvmpipe", "mesa-llvmpipe", None)]
520    necesita["C"] = necesita["B"] + [("shuma-pregunta", "shuma-pregunta", None), ("wtype", "wtype", None)]
521    necesita["D"] = necesita["C"] + [("atuq", "atuq", "ATUQ_DIR")]
522    necesita["E"] = necesita["D"]
523    necesita["F"] = necesita["D"]
524
525    dirs = {}
526    for receta, nombre, escotilla in necesita[hasta]:
527        dirs[nombre] = artefacto(receta, nombre, escotilla)
528        print(f"artefacto: {os.path.basename(dirs[nombre])}")
529
530    # El orden de las capas ES la semántica: **la última gana**. El mesa por software va al final
531    # para pisar el `iris`-only del rootfs; si fuera antes, el rootfs le devolvería el driver de una
532    # GPU Intel que esta máquina no tiene. `llvm18` va antes porque `swrast_dri.so` lo NECESITA:
533    # sin él, el loader dice «failed to open swrast: libLLVM.so.18.1» y wgpu reporta «NoAdapter»,
534    # que es el mismo síntoma que no tener driver.
535    capas_base = [ALPINE, RFS]
536    for n in ("atuq", "puriy-costura", "boveda", "shuma-pregunta", "wtype", "llvm18", "mesa-llvmpipe"):
537        if n in dirs:
538            capas_base.append(dirs[n])
539
540    # El servidor de la página de login. Se levanta aunque la etapa no lo use: su PUERTO es lo que
541    # define la URL con la que se guarda la credencial, y esa URL tiene que ser la MISMA en la
542    # etapa que guarda y en la que el navegador visita — si no, `vault.match` no encontraría nada
543    # y el fallo hablaría de la bóveda cuando el que cambió fue el puerto.
544    puerto = puerto_libre()
545    servidor = http.server.ThreadingHTTPServer(("0.0.0.0", puerto), Servidor)
546    threading.Thread(target=servidor.serve_forever, daemon=True).start()
547    url = f"http://127.0.0.1:{puerto}/"
548    print(f"la página de login se sirve en {url}")
549
550    tmp = tempfile.mkdtemp(prefix="atuq-boveda-metal-")
551    salida = os.path.join(tmp, "salida")
552    os.makedirs(salida)
553    fallos = []
554    try:
555        correr_etapas(capas_base, dirs, salida, hasta, url, fallos)
556    finally:
557        guardado = os.path.join(ROOT, "work", "atuq-boveda-metal")
558        os.makedirs(guardado, exist_ok=True)
559        for n in os.listdir(salida):
560            o = os.path.join(salida, n)
561            if os.path.isfile(o):
562                shutil.copy(o, os.path.join(guardado, n))
563        print(f"\nlogs en {guardado}")
564        shutil.rmtree(tmp, ignore_errors=True)
565
566    if fallos:
567        for f in fallos:
568            print(f"✗ {f}", file=sys.stderr)
569        raise SystemExit(1)
570    print("\n✓ todo lo que se corrió, en verde")
571
572
573def respuesta(salida, fichero, verbo):
574    """La respuesta a un verbo, leída del JSON que dejó el preguntador dentro de la jaula."""
575    crudo = leer(salida, fichero)
576    if not crudo.strip():
577        return None
578    for r in json.loads(crudo):
579        if r.get("verb") == verbo:
580            return r
581    return None
582
583
584def correr_etapas(capas, dirs, salida, hasta, url, fallos):
585    """Las etapas, en orden y cada una en su propia jaula.
586
587    Cada etapa levanta su propio compositor. Es más lento que reusar uno y es a propósito: el
588    estado que importa —la bóveda de prueba, el registro del host— vive en `/salida`, que se
589    comparte, y así una etapa no hereda procesos de la anterior. Dos corridas sobre el mismo
590    log se MEZCLAN y el resultado parece un pase; eso ya pasó en este frente (§6.1.bis).
591    """
592    orden = [c for c, _ in ETAPAS]
593    tope = orden.index(hasta)
594    escribir_preguntador(salida, url)
595    with open(os.path.join(salida, "contestar.py"), "w") as fh:
596        fh.write(CONTESTADOR)
597
598    # ── A: SIN la app. Es el estado en que estuvo el corpus entero hasta el 2026-09-18. ────────
599    print("\n== A · sin la app `boveda`, ¿qué contesta el host?")
600    cuerpo = "  python3 /salida/preguntar.py sin-boveda >> /salida/pasos-A.log 2>&1\n"
601    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-A.log"), segundos=240)
602    print(indentar(leer(salida, "pasos-A.log")))
603    estado = respuesta(salida, "sin-boveda.json", "vault.status")
604    if estado is None:
605        fallos.append("A: el preguntador no dejó respuesta — mirá pasos-A.log")
606    elif estado.get("locked") is not True:
607        fallos.append(f"A: sin la app, vault.status tenía que decir locked:true y dijo {estado}")
608    else:
609        print("   ✓ A: sin dueño, la bóveda contesta CERRADA (y con ok:true, que es la trampa)")
610    if tope < orden.index("B"):
611        return
612
613    # ── B: CON la app. El dueño abre la base y atiende el socket. ──────────────────────────────
614    print("\n== B · con la app `boveda` corriendo, ¿la bóveda está abierta?")
615    cuerpo = "  python3 /salida/preguntar.py con-boveda >> /salida/pasos-B.log 2>&1\n"
616    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-B.log"), segundos=300)
617    print(indentar(leer(salida, "pasos-B.log")))
618    estado = respuesta(salida, "con-boveda.json", "vault.status")
619    if estado is None:
620        fallos.append("B: el preguntador no dejó respuesta — mirá pasos-B.log y boveda.log")
621    elif estado.get("locked") is not False:
622        fallos.append(f"B: con la app corriendo, vault.status tenía que decir locked:false y dijo "
623                      f"{estado}.\n"
624                      "     Mirá `boveda.log`. Si dice «index out of bounds: the len is 0 but the index "
625                      "is 0» en `llimphi-hal/src/lib.rs`, NO es de la bóveda: es el muro del §7.septies "
626                      "—llimphi construye la instancia de wgpu SIN display handle, wgpu cae a la "
627                      "plataforma EGL surfaceless, esa plataforma no tiene configs de ventana, y la "
628                      "lista de formatos de la surface sale vacía—. Se destraba en llimphi, no acá, y "
629                      "hasta entonces la app no abre ventana ni levanta el socket del navegador.")
630    else:
631        print("   ✓ B: el dueño atiende — la bóveda de prueba está ABIERTA")
632    if tope < orden.index("C"):
633        return
634
635    # ── C: el instrumento. Antes de pedirle nada a la bóveda, ¿se puede contestar un diálogo? ──
636    # Va ANTES de D a propósito: si el diálogo no se puede contestar, `vault.save` va a decir
637    # `denied` — que es la respuesta CORRECTA a un consentimiento que no llegó— y sin esta etapa
638    # eso se leería como «la bóveda no guarda». Separar el instrumento del producto es lo que
639    # hace que el fallo diga cuál de los dos se rompió.
640    print("\n== C · ¿abre el diálogo en el compositor, y se lo puede contestar?")
641    cuerpo = """
642  for par in si:%s:1 no:%s:2; do
643    quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:}
644    ( python3 /salida/contestar.py "$tecla" 120 "pregunta|shuma" > /salida/contestador-C-$quien.log 2>&1 ) &
645    contestador=$!
646    /usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \
647        --option "yes=Si, contesta" --option "no=No" \
648        > /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err
649    echo "@@ dialogo $quien salio con $? y dijo: $(cat /salida/dialogo-C-$quien.json)" >> /salida/pasos-C.log
650    wait "$contestador"
651  done
652""" % (TECLA_SI, TECLA_NO)
653    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-C.log"), segundos=420)
654    print(indentar(leer(salida, "pasos-C.log")))
655    for quien in ("si", "no"):
656        print(indentar(leer(salida, f"contestador-C-{quien}.log")))
657    dijo_si = leer(salida, "dialogo-C-si.json").strip()
658    dijo_no = leer(salida, "dialogo-C-no.json").strip()
659    if '"choice":"yes"' not in dijo_si.replace(" ", ""):
660        fallos.append(f"C: al teclear «{TECLA_SI}» el diálogo tenía que contestar yes y dijo {dijo_si!r} "
661                      "— si no hay VENTANAS en el log del contestador, el cliente de GL no pintó "
662                      "(mirá dialogo-C-si.err: es donde llimphi dice por qué)")
663    elif '"choice":"no"' not in dijo_no.replace(" ", ""):
664        # El control: sin él, un diálogo que contestara «yes» pase lo que pase se vería igual.
665        fallos.append(f"C: el control falló — al teclear «{TECLA_NO}» tenía que contestar no y dijo {dijo_no!r}")
666    else:
667        print("   ✓ C: el diálogo abre, la tecla llega, y contesta lo que se le tecleó (sí Y no)")
668    if tope < orden.index("D"):
669        return
670
671    # ── D: la bóveda guarda, y sólo con permiso. ───────────────────────────────────────────────
672    print("\n== D · vault.save con el consentimiento dado, ¿guarda? ¿y después la encuentra?")
673    cuerpo = """
674  ( python3 /salida/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-D.log 2>&1 ) &
675  contestador=$!
676  python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1
677  # ⚠ `wait "$contestador"` y NO `wait` a secas: la app de la bóveda TAMBIÉN es un trabajo en
678  # segundo plano, así que un `wait` pelado no vuelve nunca, la jaula se come su timeout y a la
679  # app la mata un KILL. Y lo que se pierde ahí es lo que se acaba de guardar: `sled` no alcanza a
680  # volcar, la etapa siguiente no encuentra la credencial, y el cuadro que queda es «la bóveda no
681  # guardó» — el diagnóstico contrario al verdadero.
682  wait "$contestador"
683""" % TECLA_SI
684    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-D.log"), segundos=420)
685    print(indentar(leer(salida, "pasos-D.log")))
686    print(indentar(leer(salida, "contestador-D.log")))
687    guardo = respuesta(salida, "guardar.json", "vault.save")
688    encontro = respuesta(salida, "guardar.json", "vault.match")
689    if guardo is None:
690        fallos.append("D: el preguntador no dejó respuesta de vault.save — mirá pasos-D.log")
691    elif guardo.get("denied") is True:
692        fallos.append("D: vault.save salió DENEGADO. Si la etapa C está en verde, el diálogo se puede "
693                      "contestar y esto es del producto; si C falló, es el instrumento y no la bóveda")
694    elif not guardo.get("item"):
695        fallos.append(f"D: vault.save no devolvió el identificador de lo guardado: {guardo}")
696    elif not (encontro or {}).get("items"):
697        fallos.append(f"D: guardó pero vault.match no la encuentra para {url}: {encontro}")
698    else:
699        item = encontro["items"][0]
700        if item.get("username") != USUARIO:
701            fallos.append(f"D: la credencial encontrada no es la guardada: {item}")
702        else:
703            print(f"   ✓ D: guardada con permiso y encontrada de nuevo ({item.get('title')!r}, "
704                  f"usuario {item.get('username')!r})")
705            # Y la mitad que importa: `vault.match` NO puede traer la contraseña. El tipo del
706            # otro lado no tiene dónde ponerla, y esto lo comprueba desde afuera.
707            if any(k in item for k in ("password", "clave")):
708                fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}")
709
710    if tope < orden.index("E"):
711        return
712
713    # ── E y F: el navegador de verdad, y el par que hace que E signifique algo. ────────────────
714    # La E sola no prueba nada: una bóveda que entrega SIEMPRE se ve idéntica a una que entrega con
715    # permiso. Lo único que las separa es contestar que NO y ver que no llega nada.
716    for etapa, tecla, espera_relleno in (("E", TECLA_SI, True), ("F", TECLA_NO, False)):
717        if tope < orden.index(etapa):
718            return
719        que = "SÍ" if espera_relleno else "NO"
720        print(f"\n== {etapa} · el navegador pide la contraseña y se contesta que {que}")
721        Servidor.relleno = None
722        correr_navegador(capas, dirs, salida, url, tecla, etapa)
723        print(indentar(leer(salida, f"pasos-{etapa}.log")))
724        print(indentar(leer(salida, f"contestador-{etapa}.log")))
725        print(indentar(leer(salida, "chrome.txt")))
726        lineas = [l for l in leer(salida, f"atuq-{etapa}.log").splitlines() if l.startswith("BOVEDA ")]
727        for l in lineas[:12]:
728            print("   " + l)
729        llego = Servidor.relleno
730        if etapa == "E":
731            if "CONECTADO" not in " ".join(lineas):
732                fallos.append("E: la extensión de la bóveda no llegó a conectar con el host "
733                              "(no hay línea «BOVEDA CONECTADO») — mirá atuq-E.log")
734            if llego is None:
735                fallos.append("E: la contraseña NUNCA llegó al campo. Si el contestador no vio la "
736                              "ventana del diálogo, el muro es el del §7.septies; si la vio y tecleó, "
737                              "mirá si el DISPARO del chrome fue «ninguno» (nadie apretó el botón)")
738            elif CLAVE not in llego:
739                fallos.append(f"E: al campo llegó algo que no es la contraseña guardada: {llego!r}")
740            else:
741                print("   ✓ E: la contraseña llegó al formulario, y sólo después del sí")
742        else:
743            if llego is not None:
744                fallos.append(f"F: ⚠⚠ contestando que NO igual llegó algo al campo: {llego!r}. "
745                              "Es la falla que este guardián existe para atrapar: la bóveda entrega "
746                              "sin permiso")
747            else:
748                print("   ✓ F: con el no, al campo no llegó nada (y es el control que sostiene la E)")
749
750
751def correr_navegador(capas, dirs, salida, url, tecla, etapa):
752    """Una corrida con `atuq` de verdad: la página servida, el botón de la extensión, el diálogo.
753
754    La sonda del chrome se apenda al `atuq.cfg` del artefacto en una capa propia — se mide el
755    fichero real con la sonda encima, no una copia.
756    """
757    capa = os.path.join(salida, f"capa-{etapa}")
758    app = os.path.join(capa, APPDIR)
759    os.makedirs(app, exist_ok=True)
760    original = open(os.path.join(dirs["atuq"], APPDIR, "atuq.cfg"), encoding="utf8").read()
761    with open(os.path.join(app, "atuq.cfg"), "w", encoding="utf8") as fh:
762        fh.write(original)
763        # La espera antes de apretar: la extensión tiene que haber conectado con el host y haber
764        # mirado la pestaña. Apretar antes es preguntarle a una bóveda que todavía no se enteró.
765        fh.write(SONDA_CHROME.replace("ESPERA_MS", "9000"))
766    perfil = os.path.join(salida, f"perfil-{etapa}")
767    os.makedirs(perfil, exist_ok=True)
768    with open(os.path.join(perfil, "user.js"), "w") as fh:
769        fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
770        fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
771    cuerpo = """
772  # El patrón del contestador NO es opcional acá: con el navegador abierto, teclear «la ventana
773  # enfocada» le contestaría a la PÁGINA. Se busca la ventana del diálogo por app_id/título.
774  ( python3 /salida/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-%s.log 2>&1 ) &
775  contestador=$!
776  export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland
777  # Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
778  # con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
779  export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
780  timeout 150 /usr/bin/atuq --no-remote --profile /salida/perfil-%s '%s' >> /salida/atuq-%s.log 2>&1
781  echo "@@ atuq salio con $?" >> /salida/pasos-%s.log
782  wait "$contestador"
783""" % (tecla, etapa, etapa, url, etapa, etapa)
784    correr_jaula(capas + [capa], salida, guion_dentro(cuerpo, con_boveda=True, log=f"pasos-{etapa}.log"),
785                 segundos=420)
786
787
788def indentar(t):
789    return "\n".join("   " + l for l in t.splitlines() if l.strip())
790
791
792def escribir_preguntador(salida, url):
793    """El cliente del cable que corre DENTRO de la jaula.
794
795    Habla con el host EXACTAMENTE como lo hace la extensión —marco de 4 bytes + JSON— y no con la
796    bóveda directamente: lo que este guardián tiene que medir es la cadena entera, y el host es el
797    único que sabe encontrar al dueño.
798    """
799    with open(os.path.join(salida, "preguntar.py"), "w") as fh:
800        fh.write(CLIENTE)
801        fh.write(f'''
802QUE = sys.argv[1]
803URL = {url!r}
804BIN = "/usr/bin/puriy-costura"
805ESTADO = "/salida/hogar/estado-costura"
806os.makedirs(ESTADO, exist_ok=True)
807# `ping` va SIEMPRE y es el control de la corrida: separa «el host no contestó» de «el host
808# contestó que la bóveda está cerrada», que es la distinción que costó tres semanas.
809mensajes = [
810    {{"id": 1, "verb": "ping"}},
811    {{"id": 2, "verb": "vault.status"}},
812]
813if QUE == "guardar":
814    mensajes.append({{"id": 3, "verb": "vault.save", "url": URL,
815                      "username": {USUARIO!r}, "password": {CLAVE!r}, "title": {TITULO!r}}})
816if QUE in ("match", "guardar"):
817    mensajes.append({{"id": 4, "verb": "vault.match", "url": URL}})
818respuestas, err = hablar(BIN, ESTADO, mensajes)
819open("/salida/%s.json" % QUE, "w").write(json.dumps(respuestas, ensure_ascii=False, indent=1))
820open("/salida/%s.err" % QUE, "w").write(err)
821for r in respuestas:
822    print("   <-", json.dumps(r, ensure_ascii=False))
823''')
824
825
826if __name__ == "__main__":
827    main()