atuq: el diálogo de la bóveda no abre, y el muro no es de la bóveda — ninguna ventana llimphi pinta

`boveda` (b3:59ffd74b) y `shuma-pregunta` (b3:99763eca) sellaron en el worker. Ninguno de los dos abre
ventana, y el error no habla de contraseñas:

    panicked at llimphi-hal/src/lib.rs:1032: index out of bounds: the len is 0 but the index is 0

Esa línea es `caps.formats[0]`: la surface no tiene NI UN formato. La cadena, medida eslabón por
eslabón con RUST_LOG=wgpu_hal=debug:

    No (or unknown) windowing system ((None, Some(..))) present. Using surfaceless platform
    Trying native-render → No config found! · Trying presentation → No config found!

El `None` es `raw_display_handle`, y no es del arnés: es lo que llimphi hace A PROPÓSITO en su camino
de escritorio, con el comentario al lado («Sin display: este camino no tiene ventana todavía»). Sin
display handle wgpu abre el display EGL surfaceless, que no publica configs con WINDOW_BIT ⇒ la
surface no es presentable ⇒ cero formatos ⇒ panic.

No se nota en una máquina con Vulkan, porque ese camino elige Backends::PRIMARY y a Vulkan el display
no le hace falta. Y **ninguna imagen de takana tiene Vulkan**: las tres recetas de mesa se compilan
con `-Dvulkan-drivers=` vacío, y `vulkan-loader` sólo existe en incoming-kde sin que ningún perfil lo
declare (y un loader sin driver no es un driver). ⇒ toda app llimphi de escritorio cae al camino GL y
no puede abrir ventana. Reproducido en TRES binarios y dos versiones de wgpu: shuma-pregunta y boveda
(29) y llimphi-counter (27).

Los controles, porque la conclusión es fuerte: pedir Vulkan explícito da NoAdapter y no hay ICD ni
libvulkan en ninguna capa (la premisa está medida); con softpipe el fallo es OTRO y anterior —wgpu
pide compute shaders y softpipe se queda en GL 3.3—, así que llegar hasta acá ya exige llvmpipe; y el
compositor está levantado con su socket y su WAYLAND_DISPLAY.

Lo que esto dice del producto: el consentimiento de la bóveda no puede pedir permiso en ninguna imagen
de hoy, y como un lanzamiento fallido se traduce a «no» (Command::new falla ⇒ return false), el usuario
vería una bóveda que niega todo sin un solo error.

Se arregla en llimphi (que pase el display handle; la función `instancia_con` ya existe ahí al lado) o
con un driver Vulkan por software en el corpus. No se arregla en takana ni en el arnés.

Entra igual lo que sí se puede afirmar:
· `scripts/test-atuq-boveda-metal.py`, seis etapas declaradas, la A en VERDE — sin la app dueña el
  host contesta locked:true, que es la medición del §7.sexies ahora vigilada. Cada etapa dice qué
  afirma y la B nombra el muro en vez de dejar que se rediagnostique;
· `recipes/wtype.toml` — el corpus no tenía NINGUNA forma de meter una tecla en un compositor: todos
  los arneses de scripts/wlr miran, ninguno toca. Sellado y en ningún perfil: es instrumento.

⚠ Y una trampa del arnés, mía, que costó tres corridas: exportaba GALLIUM_DRIVER=softpipe, o sea que
yo mismo lo clavaba al driver débil. mesa-swrast y mesa-llvmpipe se ven como dos nombres de lo mismo
—«mesa por software»— y la diferencia decide si el arnés existe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-09-18 10:48:06 +00:00
co-authored by Claude Opus 5
parent 589c656262
commit f611751f86
3 changed files with 739 additions and 1 deletions
+84 -1
View File
@@ -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.56.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
+57
View File
@@ -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"]
+598
View File
@@ -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 = """<!doctype html><html><head><meta charset="utf-8"><title>Banco de prueba</title></head>
<body><h1>Banco de prueba</h1>
<form id="f" method="POST" action="/entrar">
<input id="u" name="username" type="text" autocomplete="username">
<input id="p" name="password" type="password" autocomplete="current-password">
<button id="b" type="submit">Entrar</button>
</form></body></html>
"""
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"<!doctype html><html><body>adentro</body></html>"
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("<I", len(b)) + b
fuera, err = p.communicate(entrada, timeout=180)
salida, i = [], 0
while i + 4 <= len(fuera):
n = struct.unpack("<I", fuera[i:i+4])[0]; i += 4
salida.append(json.loads(fuera[i:i+n])); i += n
return salida, err.decode(errors="replace")
'''
# ── El contestador: quien dice que sí (o que no) donde diría una persona ──────────────────────
# ⚠ Espera a que la ventana esté en el ÁRBOL DEL COMPOSITOR y con el foco, **no** a que el proceso
# exista. Que `shuma-pregunta` esté corriendo no dice que su superficie esté mapeada ni enfocada, y
# una tecla mandada antes no la recibe nadie: el diálogo se queda abierto y el resultado se lee como
# «el consentimiento no contesta», que es la conclusión contraria a la verdadera. El compositor es
# el único que sabe quién tiene el foco, así que se le pregunta a él.
CONTESTADOR = r"""
import json, subprocess, sys, time
TECLA = sys.argv[1]
ESPERA = int(sys.argv[2]) if len(sys.argv) > 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()