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

⎇ f611751f86b32fc6d16772900e6d71d45bd8b695 scripts/test-atuq-boveda-metal.py Python · 598 lines · 29.8 KB History Who touched what raw

atuq: el diálogo de la bóveda no abre, y el muro no es de la bóveda — ninguna ventana llimphi pinta f611751f8 · 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
79USUARIO = "sergio"
80CLAVE = "Zx7#qLm2pVnR"
81TITULO = "Banco de prueba"
82
83ETAPAS = [
84    ("A", "sin la app `boveda`, el host contesta locked:true (el bug del §7.sexies, vigilado)"),
85    ("B", "con la app corriendo, vault.status dice ABIERTA: el dueño atiende el socket"),
86    ("C", "el DIÁLOGO abre en el compositor y se lo puede CONTESTAR (el instrumento: wtype)"),
87    ("D", "vault.save contestado que SÍ guarda, y vault.match la encuentra después"),
88    ("E", "el navegador real: vault.fill contestado que SÍ ⇒ la contraseña llega al campo"),
89    ("F", "vault.fill contestado que NO ⇒ denied y el campo queda VACÍO (el control de E)"),
90]
91
92# El atajo del diálogo: el dígito contesta con esa opción (`atajo_de_digito` en shuma-pregunta),
93# así que «1» es la primera opción y «2» la segunda. Se teclea con `wtype`, que es la única forma
94# que tiene este corpus de meter una tecla en un compositor.
95TECLA_SI = "1"
96TECLA_NO = "2"
97
98PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>Banco de prueba</title></head>
99<body><h1>Banco de prueba</h1>
100<form id="f" method="POST" action="/entrar">
101  <input id="u" name="username" type="text" autocomplete="username">
102  <input id="p" name="password" type="password" autocomplete="current-password">
103  <button id="b" type="submit">Entrar</button>
104</form></body></html>
105"""
106
107
108def fatal(msg):
109    print(f"✗ {msg}", file=sys.stderr)
110    raise SystemExit(1)
111
112
113def artefacto(receta, nombre, escotilla=None):
114    """El artefacto VIGENTE, por `takana hash` y nunca por glob.
115
116    ⚠ La escotilla grita al usarse: el corpus es compartido y otro frente puede re-hashear una dep
117    dejando lo de acá sin sellar durante las horas de su build. Un resultado obtenido con la
118    escotilla NO se cita como «el de hoy pasa».
119    """
120    forzado = os.environ.get(escotilla) if escotilla else None
121    if forzado:
122        if not os.path.isdir(forzado):
123            fatal(f"{escotilla}={forzado} no existe")
124        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
125        return forzado
126    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
127                       cwd=ROOT, capture_output=True, text=True)
128    if r.returncode != 0:
129        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
130    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
131    d = os.path.join(STORE, f"{h}-{nombre}")
132    if not os.path.isdir(d):
133        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes:\n"
134              f"    flock -o work/.farm-build.lock {HAMMER} --store {STORE} build recipes/{receta}.toml")
135    return d
136
137
138class Servidor(http.server.SimpleHTTPRequestHandler):
139    """La página de login, servida de verdad. `atuq` la carga por la red como cualquier otra."""
140
141    def log_message(self, *a):
142        pass
143
144    def do_GET(self):
145        datos = PAGINA.encode()
146        self.send_response(200)
147        self.send_header("Content-Type", "text/html; charset=utf-8")
148        self.send_header("Content-Length", str(len(datos)))
149        self.send_header("Cache-Control", "no-store")
150        self.end_headers()
151        self.wfile.write(datos)
152
153    def do_POST(self):
154        n = int(self.headers.get("Content-Length") or 0)
155        self.rfile.read(n)
156        datos = b"<!doctype html><html><body>adentro</body></html>"
157        self.send_response(200)
158        self.send_header("Content-Type", "text/html; charset=utf-8")
159        self.send_header("Content-Length", str(len(datos)))
160        self.end_headers()
161        self.wfile.write(datos)
162
163
164def puerto_libre():
165    s = socket.socket()
166    s.bind(("0.0.0.0", 0))
167    p = s.getsockname()[1]
168    s.close()
169    return p
170
171
172# ── El cliente del cable, en Python ───────────────────────────────────────────────────────────
173# Marco de 4 bytes little-endian + JSON, que es lo que habla native messaging y lo que
174# `shared/foreign-webext` implementa del otro lado. Se escribe acá —y no se llama a la extensión—
175# porque las etapas A/B/C miden la CADENA host↔dueño↔diálogo, donde el navegador no participa:
176# meterlo sólo agregaría diez minutos y un sospechoso más a cada fallo.
177CLIENTE = r'''
178import json, os, struct, subprocess, sys
179def hablar(binario, estado, mensajes):
180    p = subprocess.Popen([binario, "--state", estado],
181                         stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
182    entrada = b""
183    for m in mensajes:
184        b = json.dumps(m).encode()
185        entrada += struct.pack("<I", len(b)) + b
186    fuera, err = p.communicate(entrada, timeout=180)
187    salida, i = [], 0
188    while i + 4 <= len(fuera):
189        n = struct.unpack("<I", fuera[i:i+4])[0]; i += 4
190        salida.append(json.loads(fuera[i:i+n])); i += n
191    return salida, err.decode(errors="replace")
192'''
193
194
195
196# ── El contestador: quien dice que sí (o que no) donde diría una persona ──────────────────────
197# ⚠ Espera a que la ventana esté en el ÁRBOL DEL COMPOSITOR y con el foco, **no** a que el proceso
198# exista. Que `shuma-pregunta` esté corriendo no dice que su superficie esté mapeada ni enfocada, y
199# una tecla mandada antes no la recibe nadie: el diálogo se queda abierto y el resultado se lee como
200# «el consentimiento no contesta», que es la conclusión contraria a la verdadera. El compositor es
201# el único que sabe quién tiene el foco, así que se le pregunta a él.
202CONTESTADOR = r"""
203import json, subprocess, sys, time
204
205TECLA = sys.argv[1]
206ESPERA = int(sys.argv[2]) if len(sys.argv) > 2 else 120
207
208
209def arbol():
210    r = subprocess.run(["/usr/bin/swaymsg", "-t", "get_tree", "-r"],
211                       capture_output=True, text=True)
212    if r.returncode != 0 or not r.stdout.strip():
213        return None
214    try:
215        return json.loads(r.stdout)
216    except Exception:
217        return None
218
219
220def ventanas(n, acc=None):
221    acc = [] if acc is None else acc
222    if n.get("pid") and n.get("type") in ("con", "floating_con"):
223        acc.append(n)
224    for hijo in (n.get("nodes") or []) + (n.get("floating_nodes") or []):
225        ventanas(hijo, acc)
226    return acc
227
228
229t0 = time.time()
230visto = None
231while time.time() - t0 < ESPERA:
232    a = arbol()
233    if a:
234        vs = ventanas(a)
235        if vs:
236            visto = vs
237            enfocada = [v for v in vs if v.get("focused")]
238            print("VENTANAS " + json.dumps(
239                [{"app_id": v.get("app_id"), "name": v.get("name"),
240                  "focused": bool(v.get("focused")),
241                  "rect": v.get("rect")} for v in vs], ensure_ascii=False))
242            if enfocada:
243                # Un respiro después del foco: el primer frame puede no estar pintado todavía y
244                # la tecla llegaría a una ventana que aún no instaló su manejador.
245                time.sleep(2)
246                r = subprocess.run(["/usr/bin/wtype", TECLA], capture_output=True, text=True)
247                print("WTYPE %s rc=%s %s" % (TECLA, r.returncode, r.stderr.strip()))
248                raise SystemExit(0)
249    time.sleep(1)
250print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000]))
251raise SystemExit(1)
252"""
253
254
255def guion_dentro(cuerpo, con_boveda, log="pasos.log", salida_rel="/salida"):
256    """El guión que corre DENTRO de la jaula, con sway headless levantado.
257
258    El compositor se levanta siempre —aunque la etapa no lo necesite— porque el diálogo es un
259    cliente wayland y `boveda` también: sin compositor no se distingue «la app no abrió la bóveda»
260    de «la app no encontró dónde pintar», que son dos causas muy distintas con el mismo síntoma.
261    """
262    arranque_boveda = ""
263    if con_boveda:
264        arranque_boveda = f"""
265  # El DUEÑO de la bóveda. Se lanza con la raíz de prueba puesta, así que abre
266  # `…/tawasuyu/boveda-de-prueba` y no la de nadie. Levanta el socket del navegador en un hilo.
267  BOVEDA_TEST_ROOT={RAIZ_DE_PRUEBA} /usr/bin/boveda >> {salida_rel}/boveda.log 2>&1 &
268  echo "@@ boveda lanzada pid $!" >> {salida_rel}/{log}
269  # Se espera al SOCKET, no a un `sleep`: un sleep que alcanza hoy no alcanza en una máquina
270  # cargada, y el fallo aparece como «la bóveda estaba cerrada» — la conclusión contraria.
271  i=0
272  while [ ! -S "$XDG_RUNTIME_DIR/boveda.sock" ] && [ $i -lt 60 ]; do sleep 1; i=$((i+1)); done
273  [ -S "$XDG_RUNTIME_DIR/boveda.sock" ] \\
274    && echo "@@ socket de la boveda LISTO en ${{i}}s" >> {salida_rel}/{log} \\
275    || echo "@@ SIN socket de la boveda tras ${{i}}s" >> {salida_rel}/{log}
276"""
277    return f"""#!/bin/sh
278export XDG_RUNTIME_DIR=/run/user/0 HOME={salida_rel}/hogar LANG=C XDG_DATA_DIRS=/usr/share
279export XDG_STATE_HOME={salida_rel}/hogar/.local/state
280export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
281# El cliente de GL va por software: ver la cabecera. `LIBGL_ALWAYS_SOFTWARE` hace que EGL elija
282# `swrast` en vez de buscar un driver de la GPU que esta máquina no tiene, y `GALLIUM_DRIVER`
283# elige CUÁL de los dos software.
284#
285# ⚠ **Y tiene que ser `llvmpipe`, no `softpipe`, y no es una preferencia.** Medido el 2026-09-18:
286# con softpipe el diálogo no abre nunca y muere con `RequestDevice("Parent device is lost")`, que no
287# nombra la causa. La causa está una línea más arriba en el log, y sólo aparece con
288# `RUST_LOG=wgpu_core=info`:
289#
290#     Renderer: softpipe · Version: 3.3 (Core Profile)
291#     indirect-validation error: ComputePipeline(Internal("The selected version doesn't support
292#         Features(BUFFER_STORAGE | COMPUTE_SHADER | DYNAMIC_ARRAY_SIZE)"))
293#
294# O sea: **wgpu exige compute shaders** y softpipe se queda en GL 3.3. `llvmpipe` da GL 4.5 y pasa.
295# Las dos son «mesa por software» y en el store son dos recetas hermanas (`mesa-swrast` es softpipe,
296# `mesa-llvmpipe` es llvmpipe + `llvm18`), así que la diferencia se ve como un detalle de nombre y
297# decide si el arnés existe o no.
298export LIBGL_ALWAYS_SOFTWARE=1 LIBGL_DRIVERS_PATH=/usr/lib/dri GALLIUM_DRIVER=llvmpipe
299mkdir -p "$XDG_RUNTIME_DIR" {salida_rel}/hogar "$XDG_STATE_HOME"; chmod 700 "$XDG_RUNTIME_DIR"
300(
301  i=0
302  while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
303  [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland" >> {salida_rel}/{log}; exit 1; }}
304  export WAYLAND_DISPLAY=wayland-1
305  export SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
306  echo "@@ wayland listo" >> {salida_rel}/{log}
307{arranque_boveda}
308{cuerpo}
309  sleep 1
310  /usr/bin/swaymsg exit 2>/dev/null
311) &
312exec /usr/bin/sway -d > {salida_rel}/sway.log 2>&1
313"""
314
315
316def correr_jaula(capas, salida, guion, segundos=420):
317    """Una corrida de bwrap con las capas dadas. La ÚLTIMA capa gana."""
318    ruta = os.path.join(salida, "dentro.sh")
319    with open(ruta, "w") as fh:
320        fh.write(guion)
321    orden = ["bwrap"]
322    for c in capas:
323        orden += ["--overlay-src", c]
324    orden += [
325        "--tmp-overlay", "/",
326        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
327        "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
328        "--bind", salida, "/salida",
329        "--uid", "0", "--gid", "0",
330        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
331        "/bin/sh", "/salida/dentro.sh",
332    ]
333    try:
334        subprocess.run(orden, check=False, timeout=segundos)
335        return True
336    except subprocess.TimeoutExpired:
337        print(f"   ⚠ la jaula no se cerró sola; se la cortó a los {segundos} s")
338        return False
339
340
341def leer(salida, nombre):
342    r = os.path.join(salida, nombre)
343    return open(r, errors="replace").read() if os.path.exists(r) else ""
344
345
346def main():
347    ap = argparse.ArgumentParser(add_help=False)
348    ap.add_argument("--hasta", default="F")
349    ap.add_argument("--list", action="store_true")
350    ap.add_argument("-h", "--help", action="store_true")
351    args, _ = ap.parse_known_args()
352    if args.help:
353        print(__doc__)
354        return
355    if args.list:
356        for clave, que in ETAPAS:
357            print(f"  {clave}  {que}")
358        return
359
360    hasta = args.hasta.upper()
361    if hasta not in [c for c, _ in ETAPAS]:
362        fatal(f"--hasta {hasta}: las etapas son " + ", ".join(c for c, _ in ETAPAS))
363
364    if not os.path.isdir(RFS):
365        fatal(f"no está el rootfs de sway en {RFS} — hidratalo con "
366              "`scripts/hydrate-profile.py escritorio-sway --into …`")
367
368    # Los artefactos se exigen POR ETAPA y no todos de entrada. No es comodidad: pedir los seis para
369    # correr la A haría que este guardián no se pudiera encender mientras se construye una de las
370    # piezas que él mismo destrabó —y la A es justamente la que mide el estado SIN esas piezas—.
371    necesita = {"A": [("puriy-costura", "puriy-costura", None)]}
372    necesita["B"] = necesita["A"] + [("boveda", "boveda", None),
373                                 ("llvm18", "llvm18", None),
374                                 ("mesa-llvmpipe", "mesa-llvmpipe", None)]
375    necesita["C"] = necesita["B"] + [("shuma-pregunta", "shuma-pregunta", None), ("wtype", "wtype", None)]
376    necesita["D"] = necesita["C"] + [("atuq", "atuq", "ATUQ_DIR")]
377    necesita["E"] = necesita["D"]
378    necesita["F"] = necesita["D"]
379
380    dirs = {}
381    for receta, nombre, escotilla in necesita[hasta]:
382        dirs[nombre] = artefacto(receta, nombre, escotilla)
383        print(f"artefacto: {os.path.basename(dirs[nombre])}")
384
385    # El orden de las capas ES la semántica: **la última gana**. El mesa por software va al final
386    # para pisar el `iris`-only del rootfs; si fuera antes, el rootfs le devolvería el driver de una
387    # GPU Intel que esta máquina no tiene. `llvm18` va antes porque `swrast_dri.so` lo NECESITA:
388    # sin él, el loader dice «failed to open swrast: libLLVM.so.18.1» y wgpu reporta «NoAdapter»,
389    # que es el mismo síntoma que no tener driver.
390    capas_base = [ALPINE, RFS]
391    for n in ("atuq", "puriy-costura", "boveda", "shuma-pregunta", "wtype", "llvm18", "mesa-llvmpipe"):
392        if n in dirs:
393            capas_base.append(dirs[n])
394
395    # El servidor de la página de login. Se levanta aunque la etapa no lo use: su PUERTO es lo que
396    # define la URL con la que se guarda la credencial, y esa URL tiene que ser la MISMA en la
397    # etapa que guarda y en la que el navegador visita — si no, `vault.match` no encontraría nada
398    # y el fallo hablaría de la bóveda cuando el que cambió fue el puerto.
399    puerto = puerto_libre()
400    servidor = http.server.ThreadingHTTPServer(("0.0.0.0", puerto), Servidor)
401    threading.Thread(target=servidor.serve_forever, daemon=True).start()
402    url = f"http://127.0.0.1:{puerto}/"
403    print(f"la página de login se sirve en {url}")
404
405    tmp = tempfile.mkdtemp(prefix="atuq-boveda-metal-")
406    salida = os.path.join(tmp, "salida")
407    os.makedirs(salida)
408    fallos = []
409    try:
410        correr_etapas(capas_base, salida, hasta, url, fallos)
411    finally:
412        guardado = os.path.join(ROOT, "work", "atuq-boveda-metal")
413        os.makedirs(guardado, exist_ok=True)
414        for n in os.listdir(salida):
415            o = os.path.join(salida, n)
416            if os.path.isfile(o):
417                shutil.copy(o, os.path.join(guardado, n))
418        print(f"\nlogs en {guardado}")
419        shutil.rmtree(tmp, ignore_errors=True)
420
421    if fallos:
422        for f in fallos:
423            print(f"✗ {f}", file=sys.stderr)
424        raise SystemExit(1)
425    print("\n✓ todo lo que se corrió, en verde")
426
427
428def respuesta(salida, fichero, verbo):
429    """La respuesta a un verbo, leída del JSON que dejó el preguntador dentro de la jaula."""
430    crudo = leer(salida, fichero)
431    if not crudo.strip():
432        return None
433    for r in json.loads(crudo):
434        if r.get("verb") == verbo:
435            return r
436    return None
437
438
439def correr_etapas(capas, salida, hasta, url, fallos):
440    """Las etapas, en orden y cada una en su propia jaula.
441
442    Cada etapa levanta su propio compositor. Es más lento que reusar uno y es a propósito: el
443    estado que importa —la bóveda de prueba, el registro del host— vive en `/salida`, que se
444    comparte, y así una etapa no hereda procesos de la anterior. Dos corridas sobre el mismo
445    log se MEZCLAN y el resultado parece un pase; eso ya pasó en este frente (§6.1.bis).
446    """
447    orden = [c for c, _ in ETAPAS]
448    tope = orden.index(hasta)
449    escribir_preguntador(salida, url)
450    with open(os.path.join(salida, "contestar.py"), "w") as fh:
451        fh.write(CONTESTADOR)
452
453    # ── A: SIN la app. Es el estado en que estuvo el corpus entero hasta el 2026-09-18. ────────
454    print("\n== A · sin la app `boveda`, ¿qué contesta el host?")
455    cuerpo = "  python3 /salida/preguntar.py sin-boveda >> /salida/pasos-A.log 2>&1\n"
456    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-A.log"), segundos=240)
457    print(indentar(leer(salida, "pasos-A.log")))
458    estado = respuesta(salida, "sin-boveda.json", "vault.status")
459    if estado is None:
460        fallos.append("A: el preguntador no dejó respuesta — mirá pasos-A.log")
461    elif estado.get("locked") is not True:
462        fallos.append(f"A: sin la app, vault.status tenía que decir locked:true y dijo {estado}")
463    else:
464        print("   ✓ A: sin dueño, la bóveda contesta CERRADA (y con ok:true, que es la trampa)")
465    if tope < orden.index("B"):
466        return
467
468    # ── B: CON la app. El dueño abre la base y atiende el socket. ──────────────────────────────
469    print("\n== B · con la app `boveda` corriendo, ¿la bóveda está abierta?")
470    cuerpo = "  python3 /salida/preguntar.py con-boveda >> /salida/pasos-B.log 2>&1\n"
471    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-B.log"), segundos=300)
472    print(indentar(leer(salida, "pasos-B.log")))
473    estado = respuesta(salida, "con-boveda.json", "vault.status")
474    if estado is None:
475        fallos.append("B: el preguntador no dejó respuesta — mirá pasos-B.log y boveda.log")
476    elif estado.get("locked") is not False:
477        fallos.append(f"B: con la app corriendo, vault.status tenía que decir locked:false y dijo "
478                      f"{estado}.\n"
479                      "     Mirá `boveda.log`. Si dice «index out of bounds: the len is 0 but the index "
480                      "is 0» en `llimphi-hal/src/lib.rs`, NO es de la bóveda: es el muro del §7.septies "
481                      "—llimphi construye la instancia de wgpu SIN display handle, wgpu cae a la "
482                      "plataforma EGL surfaceless, esa plataforma no tiene configs de ventana, y la "
483                      "lista de formatos de la surface sale vacía—. Se destraba en llimphi, no acá, y "
484                      "hasta entonces la app no abre ventana ni levanta el socket del navegador.")
485    else:
486        print("   ✓ B: el dueño atiende — la bóveda de prueba está ABIERTA")
487    if tope < orden.index("C"):
488        return
489
490    # ── C: el instrumento. Antes de pedirle nada a la bóveda, ¿se puede contestar un diálogo? ──
491    # Va ANTES de D a propósito: si el diálogo no se puede contestar, `vault.save` va a decir
492    # `denied` — que es la respuesta CORRECTA a un consentimiento que no llegó— y sin esta etapa
493    # eso se leería como «la bóveda no guarda». Separar el instrumento del producto es lo que
494    # hace que el fallo diga cuál de los dos se rompió.
495    print("\n== C · ¿abre el diálogo en el compositor, y se lo puede contestar?")
496    cuerpo = """
497  for par in si:%s:1 no:%s:2; do
498    quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:}
499    ( python3 /salida/contestar.py "$tecla" > /salida/contestador-C-$quien.log 2>&1 ) &
500    /usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \
501        --option "yes=Si, contesta" --option "no=No" \
502        > /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err
503    echo "@@ dialogo $quien salio con $? y dijo: $(cat /salida/dialogo-C-$quien.json)" >> /salida/pasos-C.log
504    wait
505  done
506""" % (TECLA_SI, TECLA_NO)
507    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-C.log"), segundos=420)
508    print(indentar(leer(salida, "pasos-C.log")))
509    for quien in ("si", "no"):
510        print(indentar(leer(salida, f"contestador-C-{quien}.log")))
511    dijo_si = leer(salida, "dialogo-C-si.json").strip()
512    dijo_no = leer(salida, "dialogo-C-no.json").strip()
513    if '"choice":"yes"' not in dijo_si.replace(" ", ""):
514        fallos.append(f"C: al teclear «{TECLA_SI}» el diálogo tenía que contestar yes y dijo {dijo_si!r} "
515                      "— si no hay VENTANAS en el log del contestador, el cliente de GL no pintó "
516                      "(mirá dialogo-C-si.err: es donde llimphi dice por qué)")
517    elif '"choice":"no"' not in dijo_no.replace(" ", ""):
518        # El control: sin él, un diálogo que contestara «yes» pase lo que pase se vería igual.
519        fallos.append(f"C: el control falló — al teclear «{TECLA_NO}» tenía que contestar no y dijo {dijo_no!r}")
520    else:
521        print("   ✓ C: el diálogo abre, la tecla llega, y contesta lo que se le tecleó (sí Y no)")
522    if tope < orden.index("D"):
523        return
524
525    # ── D: la bóveda guarda, y sólo con permiso. ───────────────────────────────────────────────
526    print("\n== D · vault.save con el consentimiento dado, ¿guarda? ¿y después la encuentra?")
527    cuerpo = """
528  ( python3 /salida/contestar.py "%s" > /salida/contestador-D.log 2>&1 ) &
529  python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1
530  wait
531""" % TECLA_SI
532    correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-D.log"), segundos=420)
533    print(indentar(leer(salida, "pasos-D.log")))
534    print(indentar(leer(salida, "contestador-D.log")))
535    guardo = respuesta(salida, "guardar.json", "vault.save")
536    encontro = respuesta(salida, "guardar.json", "vault.match")
537    if guardo is None:
538        fallos.append("D: el preguntador no dejó respuesta de vault.save — mirá pasos-D.log")
539    elif guardo.get("denied") is True:
540        fallos.append("D: vault.save salió DENEGADO. Si la etapa C está en verde, el diálogo se puede "
541                      "contestar y esto es del producto; si C falló, es el instrumento y no la bóveda")
542    elif not guardo.get("item"):
543        fallos.append(f"D: vault.save no devolvió el identificador de lo guardado: {guardo}")
544    elif not (encontro or {}).get("items"):
545        fallos.append(f"D: guardó pero vault.match no la encuentra para {url}: {encontro}")
546    else:
547        item = encontro["items"][0]
548        if item.get("username") != USUARIO:
549            fallos.append(f"D: la credencial encontrada no es la guardada: {item}")
550        else:
551            print(f"   ✓ D: guardada con permiso y encontrada de nuevo ({item.get('title')!r}, "
552                  f"usuario {item.get('username')!r})")
553            # Y la mitad que importa: `vault.match` NO puede traer la contraseña. El tipo del
554            # otro lado no tiene dónde ponerla, y esto lo comprueba desde afuera.
555            if any(k in item for k in ("password", "clave")):
556                fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}")
557
558
559def indentar(t):
560    return "\n".join("   " + l for l in t.splitlines() if l.strip())
561
562
563def escribir_preguntador(salida, url):
564    """El cliente del cable que corre DENTRO de la jaula.
565
566    Habla con el host EXACTAMENTE como lo hace la extensión —marco de 4 bytes + JSON— y no con la
567    bóveda directamente: lo que este guardián tiene que medir es la cadena entera, y el host es el
568    único que sabe encontrar al dueño.
569    """
570    with open(os.path.join(salida, "preguntar.py"), "w") as fh:
571        fh.write(CLIENTE)
572        fh.write(f'''
573QUE = sys.argv[1]
574URL = {url!r}
575BIN = "/usr/bin/puriy-costura"
576ESTADO = "/salida/hogar/estado-costura"
577os.makedirs(ESTADO, exist_ok=True)
578# `ping` va SIEMPRE y es el control de la corrida: separa «el host no contestó» de «el host
579# contestó que la bóveda está cerrada», que es la distinción que costó tres semanas.
580mensajes = [
581    {{"id": 1, "verb": "ping"}},
582    {{"id": 2, "verb": "vault.status"}},
583]
584if QUE == "guardar":
585    mensajes.append({{"id": 3, "verb": "vault.save", "url": URL,
586                      "username": {USUARIO!r}, "password": {CLAVE!r}, "title": {TITULO!r}}})
587if QUE in ("match", "guardar"):
588    mensajes.append({{"id": 4, "verb": "vault.match", "url": URL}})
589respuestas, err = hablar(BIN, ESTADO, mensajes)
590open("/salida/%s.json" % QUE, "w").write(json.dumps(respuestas, ensure_ascii=False, indent=1))
591open("/salida/%s.err" % QUE, "w").write(err)
592for r in respuestas:
593    print("   <-", json.dumps(r, ensure_ascii=False))
594''')
595
596
597if __name__ == "__main__":
598    main()