From f611751f86b32fc6d16772900e6d71d45bd8b695 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 18 Sep 2026 10:48:06 +0000 Subject: [PATCH] =?UTF-8?q?atuq:=20el=20di=C3=A1logo=20de=20la=20b=C3=B3ve?= =?UTF-8?q?da=20no=20abre,=20y=20el=20muro=20no=20es=20de=20la=20b=C3=B3ve?= =?UTF-8?q?da=20=E2=80=94=20ninguna=20ventana=20llimphi=20pinta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- docs/26-atuq-envoltorio-gecko.md | 85 ++++- recipes/wtype.toml | 57 +++ scripts/test-atuq-boveda-metal.py | 598 ++++++++++++++++++++++++++++++ 3 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 recipes/wtype.toml create mode 100644 scripts/test-atuq-boveda-metal.py 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()