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 188Rust 55Shell 30Markdown 281211 Definitions

⎇ bd8f9fbbb2a458103e19e07a9a5df4304523dae6 scripts/test-atuq-boveda-metal.py Python · 807 lines · 41.3 KB 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 bd8f9fbbb · 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"""
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()