diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index 2511c09d..4f3d1dce 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -3050,6 +3050,89 @@ dos y recién ahí escribirlo. Lo que sí queda escrito desde hoy es la pregunta
tiene que empezar —`vault.status` contra el host— porque es la que separa «la bóveda dijo que no» de
«no hay bóveda», que se ven idénticas desde la extensión.
+#### 7.septies El diálogo NO ABRE, y el muro no es de la bóveda: **ninguna app llimphi de escritorio puede pintar en esta distro** (2026-09-18)
+
+Con `boveda` y `shuma-pregunta` sellados, la etapa B del guardián de metal falla, y falla en un sitio
+que no tiene nada que ver con las contraseñas:
+
+```
+thread 'main' panicked at 02_ruway/llimphi/llimphi-hal/src/lib.rs:1032:83:
+index out of bounds: the len is 0 but the index is 0
+```
+
+Esa línea es `caps.formats[0]` sobre las capacidades de la surface. O sea: **la surface no tiene NI UN
+formato**. La cadena entera, medida eslabón por eslabón con `RUST_LOG=wgpu_hal=debug`:
+
+```
+wgpu_hal::gles::egl: No (or unknown) windowing system ((None, Some(...))) present.
+ Using surfaceless platform
+wgpu_hal::gles::egl: Trying native-render → No config found!
+wgpu_hal::gles::egl: Trying presentation → No config found!
+wgpu_hal::gles::egl: Trying off-screen → (ésta sí)
+```
+
+El `None` de ese par es `desc.raw_display_handle`, y **no es un accidente del arnés: es lo que el
+propio llimphi hace a propósito**, con su comentario al lado (`llimphi-hal/src/lib.rs`, camino de
+escritorio `new_inner`):
+
+> «Sin display: este camino no tiene ventana todavía (la surface se crea después, contra esta misma
+> instancia). Los caminos que SÍ la tienen —`new_for_raw_surface`, `recreate_for_window`— la pasan.»
+
+Sin display handle, wgpu abre el display EGL por la plataforma **surfaceless**, que por definición no
+publica configs con `WINDOW_BIT`; sin config de ventana, `presentable` es falso; sin `presentable`,
+`surface_capabilities` devuelve `None` y la lista de formatos sale vacía. Cada paso está en el log.
+
+**Por qué esto no se nota en la máquina de quien escribió llimphi, y sí acá.** Ese camino elige
+`Backends::PRIMARY` —Vulkan/Metal/DX12— y **a Vulkan el display handle no le hace falta**. Sólo cae al
+backend GL cuando no hay ninguno de los tres… que es exactamente el caso de esta distro:
+
+| | Vulkan | efecto |
+|---|---|---|
+| `recipes/mesa.toml` (iris, la de las imágenes) | `-Dvulkan-drivers=` **vacío** | no hay ICD |
+| `recipes/mesa-swrast.toml` · `recipes/mesa-llvmpipe.toml` | `-Dvulkan-drivers=` **vacío** | no hay ICD |
+| `vulkan-loader` | sólo en `incoming-kde`, y **ningún perfil lo declara** | y el loader sin driver no es un driver |
+
+⇒ **En ninguna imagen de takana hay un driver Vulkan.** Con lo cual toda app llimphi de escritorio
+—no sólo el diálogo de la bóveda— toma el camino GL, y el camino GL sin display handle no puede abrir
+ventana. Comprobado en TRES binarios distintos y con dos versiones de wgpu: `shuma-pregunta` (wgpu 29),
+`boveda` (wgpu 29) y `llimphi-counter` (wgpu 27, pineado a otro repo).
+
+**Los controles, porque la conclusión es fuerte:**
+
+· pedir Vulkan explícito (`LLIMPHI_WGPU_BACKEND=vulkan`) ⇒ **`NoAdapter`**, y `ls /usr/share/vulkan/icd.d`
+ y `ls /usr/lib/libvulkan*` no existen en ninguna capa. La premisa «no hay Vulkan» está medida, no
+ supuesta;
+· con **softpipe** el fallo es OTRO y anterior —`RequestDevice("Parent device is lost")`, con
+ `indirect-validation error: ComputePipeline(… COMPUTE_SHADER …)` una línea antes, porque softpipe se
+ queda en GL 3.3 y wgpu pide compute—. O sea que llegar hasta el panic de los formatos ya exige
+ `llvmpipe` (GL 4.5), que es lo que el arnés monta: `mesa-llvmpipe` + `llvm18`;
+· y el mismo arnés levanta `sway`, el socket wayland aparece y `WAYLAND_DISPLAY` está puesto — el
+ compositor no es el que falta.
+
+⚠ **Lo que esto dice del producto, y es lo que importa:** la bóveda de `atuq` no está «casi lista». Su
+consentimiento —`PorDialogo`, que lanza `shuma-pregunta`— **no puede pedir permiso en ninguna imagen
+de hoy**, y como el fallo del lanzamiento se traduce a «no» (`Command::new` falla ⇒ `return false`),
+lo que un usuario vería es una bóveda que niega todo, sin un error. Y no es sólo la bóveda: es
+cualquier ventana llimphi que la distro quiera abrir.
+
+**Dónde se arregla, y dónde NO.** No se arregla en takana: ni en la receta, ni en el arnés, ni
+poniendo otro mesa. Las dos salidas son de otro repo o de otra receta, y conviene decir cuál es cuál:
+
+1. **llimphi** (tawasuyu): que el camino de escritorio pase el display handle cuando lo tiene, o que
+ al caer al backend GL reconstruya la instancia con él. La función que hace falta **ya existe ahí
+ al lado** (`instancia_con`, que usan `new_for_raw_surface` y `recreate_for_window`);
+2. **un driver Vulkan por software** en el corpus (lavapipe: `-Dvulkan-drivers=swrast`), que además
+ destrabaría el muro de ScreenCast que el runbook de COSMIC documenta por otro camino. Es una
+ receta nueva y una decisión de tamaño, no un arreglo de paso.
+
+La 1 es la correcta: un navegador que pide permiso no debería depender de que la máquina tenga
+Vulkan. La 2 es la que, además, le sirve a otros frentes.
+
+**Lo que queda escrito y corriendo mientras tanto** es `scripts/test-atuq-boveda-metal.py`, con sus
+seis etapas declaradas y la A en verde: sin la app dueña, el host contesta `locked:true`. Es poco, y
+es exactamente lo que se puede afirmar hoy — que es mejor que un guardián que no existe y que uno que
+diera verde midiendo nada.
+
## 8. Plan, por unidades de trabajo
Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
@@ -3077,7 +3160,7 @@ siguiente.
| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ |
| 10 | **Foco (6.5) — 2026-09-10.** Entra al corpus la cadena `nftables` (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión `foco` MUESTRA el estado y **no tiene verbo para apagarlo**. **Falta** quién pone a `atuq` en un cgroup (delegación, decisión de arje) y quién aplica la política (root) | 6.5 | 6 ✅ |
| 11 | **Motor de inferencia local (6.7) — 2026-09-11.** Entra `recipes/llama-cpp.toml` (b10901, estática, 199 M, **REPRODUCE**), que resulta ser **un solo muro para dos pendientes**: el §6.7 y la mitad semántica del §6.3 no eran dos problemas, era que el corpus no tenía con qué correr un modelo (`pluma-llm` sólo tiene backends de nube; `rimay-verbo-fastembed` DESCARGA onnxruntime glibc + el modelo). ⚠ Y dejó medido lo que no se podía deducir: **`SOURCE_DATE_EPOCH` —la variable que nos da reproducibilidad— apagaba las SEIS perillas de ISA de ggml**, y el artefacto sellaba y corría con 0 `%ymm`. Ahora van declaradas y el `install` las comprueba. Guardián `scripts/test-llama-cpp.py` con control negativo vivo. **Faltan** el modelo (fuente pineada, no receta), los verbos del host y quién levanta el servidor | 6.7, y la mitad semántica de 6.3 | 5 ✅ |
-| 12 | **La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15.** La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —`vault.match` contesta títulos y usuarios; la contraseña sale por `vault.fill`, que **pregunta en el escritorio**—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. **Destrabada el 2026-09-16** (§7.quinquies.bis): el `Cargo.lock` de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a `23a292863` ⇒ `b3:7d63655a`, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo `strings` que había diagnosticado el hueco: `vault` de **0 a 10**, con `cas`/`sct` de control. **Falta**, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta `vault.status → locked:true` porque **el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus** — la función está apagada de fábrica en las cuatro imágenes. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`; después de eso, el guardián de METAL | el gestor de contraseñas de la suite dentro del navegador | 5 ✅ |
+| 12 | **La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15.** La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —`vault.match` contesta títulos y usuarios; la contraseña sale por `vault.fill`, que **pregunta en el escritorio**—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. **Destrabada el 2026-09-16** (§7.quinquies.bis): el `Cargo.lock` de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a `23a292863` ⇒ `b3:7d63655a`, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo `strings` que había diagnosticado el hueco: `vault` de **0 a 10**, con `cas`/`sct` de control. **Falta**, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta `vault.status → locked:true` porque **el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus** — la función está apagada de fábrica en las cuatro imágenes. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`, **las dos SELLADAS el 2026-09-18** (`b3:59ffd74b` y `b3:99763eca`, construidas en el worker). ⚠ Y con eso apareció el muro de verdad, que no es de la bóveda: **ninguna app llimphi de escritorio abre ventana en esta distro** porque no hay Vulkan en ninguna imagen y el camino GL de llimphi arma la instancia sin display handle (§7.septies). El guardián de metal existe —`scripts/test-atuq-boveda-metal.py`, seis etapas— y hoy llega hasta la A. La B se destraba en llimphi, no acá | el gestor de contraseñas de la suite dentro del navegador | 5 ✅ |
| 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — |
Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
diff --git a/recipes/wtype.toml b/recipes/wtype.toml
new file mode 100644
index 00000000..4716ac38
--- /dev/null
+++ b/recipes/wtype.toml
@@ -0,0 +1,57 @@
+# wtype 0.4 — teclear en Wayland desde un script: `wtype "hola"`, `wtype -k Return`. Es xdotool para
+# Wayland, por el protocolo `zwp_virtual_keyboard_v1` (que el XML vendorea en `protocol/`).
+#
+# ── POR QUÉ ENTRA (2026-09-18): sin esto no se puede CONTESTAR un diálogo ───────────────────────
+# El corpus no tiene **ninguna** forma de meter una tecla en un compositor: ni `wtype`, ni `ydotool`,
+# ni nada que hable el protocolo de teclado virtual (comprobado sobre `recipes/` y sobre
+# `scripts/`). Mientras lo único que había que medir era «¿la ventana pinta?», daba igual: las
+# capturas y el log del protocolo contestan eso sin tocar nada. La bóveda de `atuq` rompe esa
+# comodidad, porque su promesa **es** una pregunta: `vault.fill` no entrega la contraseña hasta que
+# una persona diga que sí en `shuma-pregunta` (SDD 26 §7.sexies). Un guardián que no puede decir que
+# sí sólo puede medir la mitad de la función — que la puerta existe—, y nunca que la puerta se abre.
+#
+# No es sólo para la bóveda: es la pieza que le faltaba a **cualquier** arnés de escritorio de este
+# repo. Todos los de `scripts/wlr/` miran; ninguno toca.
+#
+# ⚠ Y NO va declarado en ningún perfil, a propósito: es un INSTRUMENTO, no parte del producto. Mismo
+# criterio que `llama-cpp` cuando entró sin imagen que la declarara — una imagen no crece por algo
+# que sólo usa el que mide. Los arneses lo montan como capa overlay desde el store.
+#
+# ── LA REPRODUCIBILIDAD, QUE ACÁ TIENE UNA TRAMPA A LA VISTA ───────────────────────────────────
+# `meson.build` arma la cadena de versión con `__DATE__` cuando encuentra `git`. Un macro de fecha
+# de compilación es exactamente lo que rompe un binario reproducible… salvo que el compilador
+# respete `SOURCE_DATE_EPOCH`, que es lo que hacen gcc y clang (y por lo tanto `zig cc`) desde hace
+# años: con la variable puesta —y takana la pone siempre— `__DATE__` deja de ser el reloj de pared.
+# Si algún día esta receta apareciera en `why-differs`, éste es el primer sospechoso y está escrito
+# acá para no volver a buscarlo.
+#
+# ⚠ El tag `v0.4` es ANOTADO: `git ls-remote --tags` devuelve DOS shas y el bueno es el `^{}`
+# (d71be3a7…), que es el commit. Pinear el sha del objeto tag valida y después no encuentra el árbol.
+name = "wtype"
+version = "0.4"
+license = "MIT"
+
+[source]
+repo = "https://github.com/atx/wtype.git"
+commit = "d71be3a7b3f93b534a2823fd68cabd7ac2a02359" # v0.4 PELADO (^{commit})
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+zig_version = "0.13.0"
+
+[build.phases]
+configure = "meson setup output --prefix=/usr --buildtype=release --wrap-mode=nodownload"
+compile = "ninja -C output"
+install = '''
+set -e
+DESTDIR=/out ninja -C output install
+test -x /out/usr/bin/wtype || { echo "no quedó /out/usr/bin/wtype — ¿cambió el layout de meson?" >&2; exit 1; }
+'''
+
+[deps]
+# `wayland-cursor` y `wayland-client` salen los dos del paquete `wayland`; `xkbcommon` es
+# `libxkbcommon`. `rt` lo resuelve musl dentro de la libc y no hace falta declarar nada.
+build = ["meson", "samurai", "python3", "pkgconf", "wayland", "libxkbcommon", "expat", "libffi", "zlib"]
+run = ["wayland", "libxkbcommon"]
diff --git a/scripts/test-atuq-boveda-metal.py b/scripts/test-atuq-boveda-metal.py
new file mode 100644
index 00000000..91b82c2a
--- /dev/null
+++ b/scripts/test-atuq-boveda-metal.py
@@ -0,0 +1,598 @@
+#!/usr/bin/env python3
+"""¿La bóveda de `atuq` entrega una contraseña, y sólo si una persona dice que sí? (SDD 26 §7.sexies)
+
+ python3 scripts/test-atuq-boveda-metal.py # las seis etapas, en orden
+ python3 scripts/test-atuq-boveda-metal.py --hasta C # sólo hasta la etapa C (el diálogo)
+ python3 scripts/test-atuq-boveda-metal.py --list # qué afirma cada etapa, sin correr nada
+
+── POR QUÉ ESTE GUARDIÁN, Y POR QUÉ NO ALCANZA CON EL DE COHERENCIA ──────────────────────────
+`test-atuq-boveda-coherente.py` mira los CINCO lugares donde una extensión se enchufa y tarda un
+segundo. Es lo que hay que correr primero y no reemplaza a nada. Lo que no puede contestar es si del
+otro lado del cable hay alguien: el 2026-09-18 los cinco lugares estaban en orden —manifiesto,
+política, permiso, prefs y el commit del host con sus verbos— y la bóveda contestaba **cerrada** en
+las cuatro imágenes, porque el DUEÑO de la base y el DIÁLOGO de consentimiento no estaban en el
+corpus. Ningún guardián de ficheros podía verlo; éste empieza justamente por ahí.
+
+── LAS SEIS ETAPAS, Y QUÉ AFIRMA CADA UNA ────────────────────────────────────────────────────
+ A sin la app `boveda`, el host contesta `locked:true` ← el control, y es el bug del §7.sexies
+ B con la app corriendo, `vault.status` dice ABIERTA ← el dueño existe y atiende el socket
+ C `vault.save` abre el DIÁLOGO y se lo puede contestar ← el consentimiento es real y se ve
+ D el navegador, sobre una página servida por HTTP, encuentra la credencial (`vault.match`)
+ E `vault.fill` contestado que SÍ ⇒ la contraseña llega al campo del formulario
+ F `vault.fill` contestado que NO ⇒ `denied`, y el campo queda VACÍO ← el control de E
+
+La etapa A no es decoración: **«cerrada» es una respuesta exitosa** (`ok:true`), así que sin ese
+control un guardián que midiera sólo B no distinguiría «la bóveda abrió» de «nadie preguntó bien».
+Y la F es la que hace que la E signifique algo: una bóveda que entrega siempre se ve idéntica a una
+que entrega con permiso, y la diferencia es la única razón por la que esto existe.
+
+── LO QUE HIZO FALTA PARA PODER CONTESTAR UNA PREGUNTA ───────────────────────────────────────
+El corpus no tenía **ninguna** forma de meter una tecla en un compositor. Todos los arneses de
+`scripts/wlr/` miran; ninguno toca. Entra `wtype` (protocolo `zwp_virtual_keyboard_v1`) como
+INSTRUMENTO —no va en ninguna imagen— y con él se contesta el diálogo igual que lo haría una
+persona: flechas y `Enter`.
+
+⚠ **Y el render es por SOFTWARE a propósito, con `llvmpipe` y no con `softpipe`.** `shuma-pregunta`
+y `boveda` son llimphi/wgpu, o sea clientes de GL; el `mesa` de las imágenes es **iris-only** (Intel)
+y esta máquina tiene una virtio-gpu, así que EGL no encontraría driver. Se montan `mesa-llvmpipe` +
+`llvm18` como últimas capas. Con `mesa-swrast` (softpipe, GL 3.3) el diálogo **no abre**: wgpu pide
+compute shaders y muere con `RequestDevice("Parent device is lost")`, un mensaje que no nombra la
+causa. Es el entorno de prueba, no el artefacto: en la imagen de verdad el driver lo pone el metal.
+
+⚠⚠ **DÓNDE ESTÁ HOY (2026-09-18): la etapa A pasa y la B choca contra un muro que no es de la
+bóveda.** `boveda` y `shuma-pregunta` están sellados, y ninguno de los dos abre ventana: llimphi
+construye la instancia de wgpu **sin display handle** —su propio comentario lo dice: «este camino no
+tiene ventana todavía»—, wgpu cae entonces a la plataforma EGL **surfaceless**, que por definición no
+tiene configs de ventana, y `surface.get_capabilities()` devuelve **cero formatos**; llimphi hace
+`caps.formats[0]` y panica. En una máquina con Vulkan no se nota, porque el backend PRIMARY no
+necesita el display — y **ninguna imagen de takana tiene Vulkan**: las tres recetas de mesa se
+compilan con `-Dvulkan-drivers=` vacío. Ver SDD 26 §7.septies. Este guardián queda escrito y
+CORRIENDO hasta donde se puede: la A en verde es la medición del §7.sexies, vigilada.
+
+REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`) y, sellados,
+`atuq`, `puriy-costura`, `boveda`, `shuma-pregunta`, `wtype`, `llvm18` y `mesa-llvmpipe`.
+"""
+import argparse
+import http.server
+import json
+import os
+import shutil
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
+ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
+HAMMER = os.path.join(ROOT, "target/release/takana")
+STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
+
+# La raíz de la bóveda DE PRUEBA. Con esta variable puesta, la app deriva sus claves de esta cadena
+# en vez de la seed de identidad del llavero del kernel —que en ninguna máquina de desarrollo está
+# desbloqueada— y abre `…/tawasuyu/boveda-de-prueba`, que es OTRA bóveda, vacía, y lo dice en
+# pantalla. Sin esto este guardián no se podría encender en ningún lado.
+RAIZ_DE_PRUEBA = "metal-de-atuq"
+
+USUARIO = "sergio"
+CLAVE = "Zx7#qLm2pVnR"
+TITULO = "Banco de prueba"
+
+ETAPAS = [
+ ("A", "sin la app `boveda`, el host contesta locked:true (el bug del §7.sexies, vigilado)"),
+ ("B", "con la app corriendo, vault.status dice ABIERTA: el dueño atiende el socket"),
+ ("C", "el DIÁLOGO abre en el compositor y se lo puede CONTESTAR (el instrumento: wtype)"),
+ ("D", "vault.save contestado que SÍ guarda, y vault.match la encuentra después"),
+ ("E", "el navegador real: vault.fill contestado que SÍ ⇒ la contraseña llega al campo"),
+ ("F", "vault.fill contestado que NO ⇒ denied y el campo queda VACÍO (el control de E)"),
+]
+
+# El atajo del diálogo: el dígito contesta con esa opción (`atajo_de_digito` en shuma-pregunta),
+# así que «1» es la primera opción y «2» la segunda. Se teclea con `wtype`, que es la única forma
+# que tiene este corpus de meter una tecla en un compositor.
+TECLA_SI = "1"
+TECLA_NO = "2"
+
+PAGINA = """
Banco de prueba
+Banco de prueba
+
+"""
+
+
+def fatal(msg):
+ print(f"✗ {msg}", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def artefacto(receta, nombre, escotilla=None):
+ """El artefacto VIGENTE, por `takana hash` y nunca por glob.
+
+ ⚠ La escotilla grita al usarse: el corpus es compartido y otro frente puede re-hashear una dep
+ dejando lo de acá sin sellar durante las horas de su build. Un resultado obtenido con la
+ escotilla NO se cita como «el de hoy pasa».
+ """
+ forzado = os.environ.get(escotilla) if escotilla else None
+ if forzado:
+ if not os.path.isdir(forzado):
+ fatal(f"{escotilla}={forzado} no existe")
+ print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
+ return forzado
+ r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
+ cwd=ROOT, capture_output=True, text=True)
+ if r.returncode != 0:
+ fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
+ h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
+ d = os.path.join(STORE, f"{h}-{nombre}")
+ if not os.path.isdir(d):
+ fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes:\n"
+ f" flock -o work/.farm-build.lock {HAMMER} --store {STORE} build recipes/{receta}.toml")
+ return d
+
+
+class Servidor(http.server.SimpleHTTPRequestHandler):
+ """La página de login, servida de verdad. `atuq` la carga por la red como cualquier otra."""
+
+ def log_message(self, *a):
+ pass
+
+ def do_GET(self):
+ datos = PAGINA.encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(datos)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(datos)
+
+ def do_POST(self):
+ n = int(self.headers.get("Content-Length") or 0)
+ self.rfile.read(n)
+ datos = b"adentro"
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(datos)))
+ self.end_headers()
+ self.wfile.write(datos)
+
+
+def puerto_libre():
+ s = socket.socket()
+ s.bind(("0.0.0.0", 0))
+ p = s.getsockname()[1]
+ s.close()
+ return p
+
+
+# ── El cliente del cable, en Python ───────────────────────────────────────────────────────────
+# Marco de 4 bytes little-endian + JSON, que es lo que habla native messaging y lo que
+# `shared/foreign-webext` implementa del otro lado. Se escribe acá —y no se llama a la extensión—
+# porque las etapas A/B/C miden la CADENA host↔dueño↔diálogo, donde el navegador no participa:
+# meterlo sólo agregaría diez minutos y un sospechoso más a cada fallo.
+CLIENTE = r'''
+import json, os, struct, subprocess, sys
+def hablar(binario, estado, mensajes):
+ p = subprocess.Popen([binario, "--state", estado],
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ entrada = b""
+ for m in mensajes:
+ b = json.dumps(m).encode()
+ entrada += struct.pack(" 2 else 120
+
+
+def arbol():
+ r = subprocess.run(["/usr/bin/swaymsg", "-t", "get_tree", "-r"],
+ capture_output=True, text=True)
+ if r.returncode != 0 or not r.stdout.strip():
+ return None
+ try:
+ return json.loads(r.stdout)
+ except Exception:
+ return None
+
+
+def ventanas(n, acc=None):
+ acc = [] if acc is None else acc
+ if n.get("pid") and n.get("type") in ("con", "floating_con"):
+ acc.append(n)
+ for hijo in (n.get("nodes") or []) + (n.get("floating_nodes") or []):
+ ventanas(hijo, acc)
+ return acc
+
+
+t0 = time.time()
+visto = None
+while time.time() - t0 < ESPERA:
+ a = arbol()
+ if a:
+ vs = ventanas(a)
+ if vs:
+ visto = vs
+ enfocada = [v for v in vs if v.get("focused")]
+ print("VENTANAS " + json.dumps(
+ [{"app_id": v.get("app_id"), "name": v.get("name"),
+ "focused": bool(v.get("focused")),
+ "rect": v.get("rect")} for v in vs], ensure_ascii=False))
+ if enfocada:
+ # Un respiro después del foco: el primer frame puede no estar pintado todavía y
+ # la tecla llegaría a una ventana que aún no instaló su manejador.
+ time.sleep(2)
+ r = subprocess.run(["/usr/bin/wtype", TECLA], capture_output=True, text=True)
+ print("WTYPE %s rc=%s %s" % (TECLA, r.returncode, r.stderr.strip()))
+ raise SystemExit(0)
+ time.sleep(1)
+print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000]))
+raise SystemExit(1)
+"""
+
+
+def guion_dentro(cuerpo, con_boveda, log="pasos.log", salida_rel="/salida"):
+ """El guión que corre DENTRO de la jaula, con sway headless levantado.
+
+ El compositor se levanta siempre —aunque la etapa no lo necesite— porque el diálogo es un
+ cliente wayland y `boveda` también: sin compositor no se distingue «la app no abrió la bóveda»
+ de «la app no encontró dónde pintar», que son dos causas muy distintas con el mismo síntoma.
+ """
+ arranque_boveda = ""
+ if con_boveda:
+ arranque_boveda = f"""
+ # El DUEÑO de la bóveda. Se lanza con la raíz de prueba puesta, así que abre
+ # `…/tawasuyu/boveda-de-prueba` y no la de nadie. Levanta el socket del navegador en un hilo.
+ BOVEDA_TEST_ROOT={RAIZ_DE_PRUEBA} /usr/bin/boveda >> {salida_rel}/boveda.log 2>&1 &
+ echo "@@ boveda lanzada pid $!" >> {salida_rel}/{log}
+ # Se espera al SOCKET, no a un `sleep`: un sleep que alcanza hoy no alcanza en una máquina
+ # cargada, y el fallo aparece como «la bóveda estaba cerrada» — la conclusión contraria.
+ i=0
+ while [ ! -S "$XDG_RUNTIME_DIR/boveda.sock" ] && [ $i -lt 60 ]; do sleep 1; i=$((i+1)); done
+ [ -S "$XDG_RUNTIME_DIR/boveda.sock" ] \\
+ && echo "@@ socket de la boveda LISTO en ${{i}}s" >> {salida_rel}/{log} \\
+ || echo "@@ SIN socket de la boveda tras ${{i}}s" >> {salida_rel}/{log}
+"""
+ return f"""#!/bin/sh
+export XDG_RUNTIME_DIR=/run/user/0 HOME={salida_rel}/hogar LANG=C XDG_DATA_DIRS=/usr/share
+export XDG_STATE_HOME={salida_rel}/hogar/.local/state
+export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
+# El cliente de GL va por software: ver la cabecera. `LIBGL_ALWAYS_SOFTWARE` hace que EGL elija
+# `swrast` en vez de buscar un driver de la GPU que esta máquina no tiene, y `GALLIUM_DRIVER`
+# elige CUÁL de los dos software.
+#
+# ⚠ **Y tiene que ser `llvmpipe`, no `softpipe`, y no es una preferencia.** Medido el 2026-09-18:
+# con softpipe el diálogo no abre nunca y muere con `RequestDevice("Parent device is lost")`, que no
+# nombra la causa. La causa está una línea más arriba en el log, y sólo aparece con
+# `RUST_LOG=wgpu_core=info`:
+#
+# Renderer: softpipe · Version: 3.3 (Core Profile)
+# indirect-validation error: ComputePipeline(Internal("The selected version doesn't support
+# Features(BUFFER_STORAGE | COMPUTE_SHADER | DYNAMIC_ARRAY_SIZE)"))
+#
+# O sea: **wgpu exige compute shaders** y softpipe se queda en GL 3.3. `llvmpipe` da GL 4.5 y pasa.
+# Las dos son «mesa por software» y en el store son dos recetas hermanas (`mesa-swrast` es softpipe,
+# `mesa-llvmpipe` es llvmpipe + `llvm18`), así que la diferencia se ve como un detalle de nombre y
+# decide si el arnés existe o no.
+export LIBGL_ALWAYS_SOFTWARE=1 LIBGL_DRIVERS_PATH=/usr/lib/dri GALLIUM_DRIVER=llvmpipe
+mkdir -p "$XDG_RUNTIME_DIR" {salida_rel}/hogar "$XDG_STATE_HOME"; chmod 700 "$XDG_RUNTIME_DIR"
+(
+ i=0
+ while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
+ [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland" >> {salida_rel}/{log}; exit 1; }}
+ export WAYLAND_DISPLAY=wayland-1
+ export SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
+ echo "@@ wayland listo" >> {salida_rel}/{log}
+{arranque_boveda}
+{cuerpo}
+ sleep 1
+ /usr/bin/swaymsg exit 2>/dev/null
+) &
+exec /usr/bin/sway -d > {salida_rel}/sway.log 2>&1
+"""
+
+
+def correr_jaula(capas, salida, guion, segundos=420):
+ """Una corrida de bwrap con las capas dadas. La ÚLTIMA capa gana."""
+ ruta = os.path.join(salida, "dentro.sh")
+ with open(ruta, "w") as fh:
+ fh.write(guion)
+ orden = ["bwrap"]
+ for c in capas:
+ orden += ["--overlay-src", c]
+ orden += [
+ "--tmp-overlay", "/",
+ "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
+ "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
+ "--bind", salida, "/salida",
+ "--uid", "0", "--gid", "0",
+ "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
+ "/bin/sh", "/salida/dentro.sh",
+ ]
+ try:
+ subprocess.run(orden, check=False, timeout=segundos)
+ return True
+ except subprocess.TimeoutExpired:
+ print(f" ⚠ la jaula no se cerró sola; se la cortó a los {segundos} s")
+ return False
+
+
+def leer(salida, nombre):
+ r = os.path.join(salida, nombre)
+ return open(r, errors="replace").read() if os.path.exists(r) else ""
+
+
+def main():
+ ap = argparse.ArgumentParser(add_help=False)
+ ap.add_argument("--hasta", default="F")
+ ap.add_argument("--list", action="store_true")
+ ap.add_argument("-h", "--help", action="store_true")
+ args, _ = ap.parse_known_args()
+ if args.help:
+ print(__doc__)
+ return
+ if args.list:
+ for clave, que in ETAPAS:
+ print(f" {clave} {que}")
+ return
+
+ hasta = args.hasta.upper()
+ if hasta not in [c for c, _ in ETAPAS]:
+ fatal(f"--hasta {hasta}: las etapas son " + ", ".join(c for c, _ in ETAPAS))
+
+ if not os.path.isdir(RFS):
+ fatal(f"no está el rootfs de sway en {RFS} — hidratalo con "
+ "`scripts/hydrate-profile.py escritorio-sway --into …`")
+
+ # Los artefactos se exigen POR ETAPA y no todos de entrada. No es comodidad: pedir los seis para
+ # correr la A haría que este guardián no se pudiera encender mientras se construye una de las
+ # piezas que él mismo destrabó —y la A es justamente la que mide el estado SIN esas piezas—.
+ necesita = {"A": [("puriy-costura", "puriy-costura", None)]}
+ necesita["B"] = necesita["A"] + [("boveda", "boveda", None),
+ ("llvm18", "llvm18", None),
+ ("mesa-llvmpipe", "mesa-llvmpipe", None)]
+ necesita["C"] = necesita["B"] + [("shuma-pregunta", "shuma-pregunta", None), ("wtype", "wtype", None)]
+ necesita["D"] = necesita["C"] + [("atuq", "atuq", "ATUQ_DIR")]
+ necesita["E"] = necesita["D"]
+ necesita["F"] = necesita["D"]
+
+ dirs = {}
+ for receta, nombre, escotilla in necesita[hasta]:
+ dirs[nombre] = artefacto(receta, nombre, escotilla)
+ print(f"artefacto: {os.path.basename(dirs[nombre])}")
+
+ # El orden de las capas ES la semántica: **la última gana**. El mesa por software va al final
+ # para pisar el `iris`-only del rootfs; si fuera antes, el rootfs le devolvería el driver de una
+ # GPU Intel que esta máquina no tiene. `llvm18` va antes porque `swrast_dri.so` lo NECESITA:
+ # sin él, el loader dice «failed to open swrast: libLLVM.so.18.1» y wgpu reporta «NoAdapter»,
+ # que es el mismo síntoma que no tener driver.
+ capas_base = [ALPINE, RFS]
+ for n in ("atuq", "puriy-costura", "boveda", "shuma-pregunta", "wtype", "llvm18", "mesa-llvmpipe"):
+ if n in dirs:
+ capas_base.append(dirs[n])
+
+ # El servidor de la página de login. Se levanta aunque la etapa no lo use: su PUERTO es lo que
+ # define la URL con la que se guarda la credencial, y esa URL tiene que ser la MISMA en la
+ # etapa que guarda y en la que el navegador visita — si no, `vault.match` no encontraría nada
+ # y el fallo hablaría de la bóveda cuando el que cambió fue el puerto.
+ puerto = puerto_libre()
+ servidor = http.server.ThreadingHTTPServer(("0.0.0.0", puerto), Servidor)
+ threading.Thread(target=servidor.serve_forever, daemon=True).start()
+ url = f"http://127.0.0.1:{puerto}/"
+ print(f"la página de login se sirve en {url}")
+
+ tmp = tempfile.mkdtemp(prefix="atuq-boveda-metal-")
+ salida = os.path.join(tmp, "salida")
+ os.makedirs(salida)
+ fallos = []
+ try:
+ correr_etapas(capas_base, salida, hasta, url, fallos)
+ finally:
+ guardado = os.path.join(ROOT, "work", "atuq-boveda-metal")
+ os.makedirs(guardado, exist_ok=True)
+ for n in os.listdir(salida):
+ o = os.path.join(salida, n)
+ if os.path.isfile(o):
+ shutil.copy(o, os.path.join(guardado, n))
+ print(f"\nlogs en {guardado}")
+ shutil.rmtree(tmp, ignore_errors=True)
+
+ if fallos:
+ for f in fallos:
+ print(f"✗ {f}", file=sys.stderr)
+ raise SystemExit(1)
+ print("\n✓ todo lo que se corrió, en verde")
+
+
+def respuesta(salida, fichero, verbo):
+ """La respuesta a un verbo, leída del JSON que dejó el preguntador dentro de la jaula."""
+ crudo = leer(salida, fichero)
+ if not crudo.strip():
+ return None
+ for r in json.loads(crudo):
+ if r.get("verb") == verbo:
+ return r
+ return None
+
+
+def correr_etapas(capas, salida, hasta, url, fallos):
+ """Las etapas, en orden y cada una en su propia jaula.
+
+ Cada etapa levanta su propio compositor. Es más lento que reusar uno y es a propósito: el
+ estado que importa —la bóveda de prueba, el registro del host— vive en `/salida`, que se
+ comparte, y así una etapa no hereda procesos de la anterior. Dos corridas sobre el mismo
+ log se MEZCLAN y el resultado parece un pase; eso ya pasó en este frente (§6.1.bis).
+ """
+ orden = [c for c, _ in ETAPAS]
+ tope = orden.index(hasta)
+ escribir_preguntador(salida, url)
+ with open(os.path.join(salida, "contestar.py"), "w") as fh:
+ fh.write(CONTESTADOR)
+
+ # ── A: SIN la app. Es el estado en que estuvo el corpus entero hasta el 2026-09-18. ────────
+ print("\n== A · sin la app `boveda`, ¿qué contesta el host?")
+ cuerpo = " python3 /salida/preguntar.py sin-boveda >> /salida/pasos-A.log 2>&1\n"
+ correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-A.log"), segundos=240)
+ print(indentar(leer(salida, "pasos-A.log")))
+ estado = respuesta(salida, "sin-boveda.json", "vault.status")
+ if estado is None:
+ fallos.append("A: el preguntador no dejó respuesta — mirá pasos-A.log")
+ elif estado.get("locked") is not True:
+ fallos.append(f"A: sin la app, vault.status tenía que decir locked:true y dijo {estado}")
+ else:
+ print(" ✓ A: sin dueño, la bóveda contesta CERRADA (y con ok:true, que es la trampa)")
+ if tope < orden.index("B"):
+ return
+
+ # ── B: CON la app. El dueño abre la base y atiende el socket. ──────────────────────────────
+ print("\n== B · con la app `boveda` corriendo, ¿la bóveda está abierta?")
+ cuerpo = " python3 /salida/preguntar.py con-boveda >> /salida/pasos-B.log 2>&1\n"
+ correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-B.log"), segundos=300)
+ print(indentar(leer(salida, "pasos-B.log")))
+ estado = respuesta(salida, "con-boveda.json", "vault.status")
+ if estado is None:
+ fallos.append("B: el preguntador no dejó respuesta — mirá pasos-B.log y boveda.log")
+ elif estado.get("locked") is not False:
+ fallos.append(f"B: con la app corriendo, vault.status tenía que decir locked:false y dijo "
+ f"{estado}.\n"
+ " Mirá `boveda.log`. Si dice «index out of bounds: the len is 0 but the index "
+ "is 0» en `llimphi-hal/src/lib.rs`, NO es de la bóveda: es el muro del §7.septies "
+ "—llimphi construye la instancia de wgpu SIN display handle, wgpu cae a la "
+ "plataforma EGL surfaceless, esa plataforma no tiene configs de ventana, y la "
+ "lista de formatos de la surface sale vacía—. Se destraba en llimphi, no acá, y "
+ "hasta entonces la app no abre ventana ni levanta el socket del navegador.")
+ else:
+ print(" ✓ B: el dueño atiende — la bóveda de prueba está ABIERTA")
+ if tope < orden.index("C"):
+ return
+
+ # ── C: el instrumento. Antes de pedirle nada a la bóveda, ¿se puede contestar un diálogo? ──
+ # Va ANTES de D a propósito: si el diálogo no se puede contestar, `vault.save` va a decir
+ # `denied` — que es la respuesta CORRECTA a un consentimiento que no llegó— y sin esta etapa
+ # eso se leería como «la bóveda no guarda». Separar el instrumento del producto es lo que
+ # hace que el fallo diga cuál de los dos se rompió.
+ print("\n== C · ¿abre el diálogo en el compositor, y se lo puede contestar?")
+ cuerpo = """
+ for par in si:%s:1 no:%s:2; do
+ quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:}
+ ( python3 /salida/contestar.py "$tecla" > /salida/contestador-C-$quien.log 2>&1 ) &
+ /usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \
+ --option "yes=Si, contesta" --option "no=No" \
+ > /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err
+ echo "@@ dialogo $quien salio con $? y dijo: $(cat /salida/dialogo-C-$quien.json)" >> /salida/pasos-C.log
+ wait
+ done
+""" % (TECLA_SI, TECLA_NO)
+ correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=False, log="pasos-C.log"), segundos=420)
+ print(indentar(leer(salida, "pasos-C.log")))
+ for quien in ("si", "no"):
+ print(indentar(leer(salida, f"contestador-C-{quien}.log")))
+ dijo_si = leer(salida, "dialogo-C-si.json").strip()
+ dijo_no = leer(salida, "dialogo-C-no.json").strip()
+ if '"choice":"yes"' not in dijo_si.replace(" ", ""):
+ fallos.append(f"C: al teclear «{TECLA_SI}» el diálogo tenía que contestar yes y dijo {dijo_si!r} "
+ "— si no hay VENTANAS en el log del contestador, el cliente de GL no pintó "
+ "(mirá dialogo-C-si.err: es donde llimphi dice por qué)")
+ elif '"choice":"no"' not in dijo_no.replace(" ", ""):
+ # El control: sin él, un diálogo que contestara «yes» pase lo que pase se vería igual.
+ fallos.append(f"C: el control falló — al teclear «{TECLA_NO}» tenía que contestar no y dijo {dijo_no!r}")
+ else:
+ print(" ✓ C: el diálogo abre, la tecla llega, y contesta lo que se le tecleó (sí Y no)")
+ if tope < orden.index("D"):
+ return
+
+ # ── D: la bóveda guarda, y sólo con permiso. ───────────────────────────────────────────────
+ print("\n== D · vault.save con el consentimiento dado, ¿guarda? ¿y después la encuentra?")
+ cuerpo = """
+ ( python3 /salida/contestar.py "%s" > /salida/contestador-D.log 2>&1 ) &
+ python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1
+ wait
+""" % TECLA_SI
+ correr_jaula(capas, salida, guion_dentro(cuerpo, con_boveda=True, log="pasos-D.log"), segundos=420)
+ print(indentar(leer(salida, "pasos-D.log")))
+ print(indentar(leer(salida, "contestador-D.log")))
+ guardo = respuesta(salida, "guardar.json", "vault.save")
+ encontro = respuesta(salida, "guardar.json", "vault.match")
+ if guardo is None:
+ fallos.append("D: el preguntador no dejó respuesta de vault.save — mirá pasos-D.log")
+ elif guardo.get("denied") is True:
+ fallos.append("D: vault.save salió DENEGADO. Si la etapa C está en verde, el diálogo se puede "
+ "contestar y esto es del producto; si C falló, es el instrumento y no la bóveda")
+ elif not guardo.get("item"):
+ fallos.append(f"D: vault.save no devolvió el identificador de lo guardado: {guardo}")
+ elif not (encontro or {}).get("items"):
+ fallos.append(f"D: guardó pero vault.match no la encuentra para {url}: {encontro}")
+ else:
+ item = encontro["items"][0]
+ if item.get("username") != USUARIO:
+ fallos.append(f"D: la credencial encontrada no es la guardada: {item}")
+ else:
+ print(f" ✓ D: guardada con permiso y encontrada de nuevo ({item.get('title')!r}, "
+ f"usuario {item.get('username')!r})")
+ # Y la mitad que importa: `vault.match` NO puede traer la contraseña. El tipo del
+ # otro lado no tiene dónde ponerla, y esto lo comprueba desde afuera.
+ if any(k in item for k in ("password", "clave")):
+ fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}")
+
+
+def indentar(t):
+ return "\n".join(" " + l for l in t.splitlines() if l.strip())
+
+
+def escribir_preguntador(salida, url):
+ """El cliente del cable que corre DENTRO de la jaula.
+
+ Habla con el host EXACTAMENTE como lo hace la extensión —marco de 4 bytes + JSON— y no con la
+ bóveda directamente: lo que este guardián tiene que medir es la cadena entera, y el host es el
+ único que sabe encontrar al dueño.
+ """
+ with open(os.path.join(salida, "preguntar.py"), "w") as fh:
+ fh.write(CLIENTE)
+ fh.write(f'''
+QUE = sys.argv[1]
+URL = {url!r}
+BIN = "/usr/bin/puriy-costura"
+ESTADO = "/salida/hogar/estado-costura"
+os.makedirs(ESTADO, exist_ok=True)
+# `ping` va SIEMPRE y es el control de la corrida: separa «el host no contestó» de «el host
+# contestó que la bóveda está cerrada», que es la distinción que costó tres semanas.
+mensajes = [
+ {{"id": 1, "verb": "ping"}},
+ {{"id": 2, "verb": "vault.status"}},
+]
+if QUE == "guardar":
+ mensajes.append({{"id": 3, "verb": "vault.save", "url": URL,
+ "username": {USUARIO!r}, "password": {CLAVE!r}, "title": {TITULO!r}}})
+if QUE in ("match", "guardar"):
+ mensajes.append({{"id": 4, "verb": "vault.match", "url": URL}})
+respuestas, err = hablar(BIN, ESTADO, mensajes)
+open("/salida/%s.json" % QUE, "w").write(json.dumps(respuestas, ensure_ascii=False, indent=1))
+open("/salida/%s.err" % QUE, "w").write(err)
+for r in respuestas:
+ print(" <-", json.dumps(r, ensure_ascii=False))
+''')
+
+
+if __name__ == "__main__":
+ main()