atuq: la bóveda ANDA hasta la etapa D — y con el navegador abierto quedaba muda por un tercer fallo

Con el arreglo de llimghi sellado, el guardián de metal pasó de la A a la D:

    A ✓ sin la app dueña, el host contesta locked:true (y con ok:true, que es la trampa)
    B ✓ con la app, vault.status dice ABIERTA — el socket sube en 1 s
    C ✓ el diálogo ABRE en el compositor y se lo CONTESTA: «1» ⇒ yes, «2» ⇒ no (los dos sentidos)
    D ✓ vault.save con consentimiento real guarda, y vault.match la encuentra SIN contraseña
    E ✗ el navegador pide la contraseña y no llega nunca

La E destapó un fallo que no es de atuq ni de llimphi: el dueño de la bóveda atendía de a UN cliente
—`atender_cliente` no vuelve hasta que el cliente se va, y se lo llamaba en el hilo del accept—, así
que la primera conexión se quedaba con él mientras viviera. Y el caso normal es ése: Gecko lanza un
`puriy-costura` por PUERTO, ocho en atuq, y todos conectan al arrancar. La extensión de la bóveda
mandaba `vault.match` y no volvía nunca: sin insignia, sin log y sin error, indistinguible de «este
sitio no tiene contraseñas».

Control sin navegador, en los dos sentidos: un solo cliente contesta en milisegundos; con otro host
conectado y quieto, la misma pregunta queda colgada y la mata el timeout a los 30 s.

Arreglado en tawasuyu (`cf3540460`, un hilo por conexión; los diálogos los serializa ahora el Mutex
de la bóveda) con su test de regresión, que además cazó que el arreglo obvio —clonar el Dueno— borra
el socket en el Drop del primer hilo que termina. Los siete tests que ya había no podían ver el fallo:
abrían un cliente por vez, que es justo lo que producción nunca hace.

⚠ Y subir el pin volvió a chocar con el `Cargo.lock` abierto de tawasuyu. Lo que importa para la
próxima es CUÁL operación lo cierra: `cargo metadata` sin `--locked` da **1 línea** de diff y cero
checksums movidos; `cargo generate-lockfile` da 6.305 líneas y 750 checksums, e invalidaría el
vendoreo de todos. Publicado en `b80f7567c` con índice temporal (estaba MM).

Del lado del guardián, tres cosas que salieron de fallar:
· el contestador INSISTE hasta que la ventana se va — con el mismo `wtype rc=0`, una corrida
  contestaba y otra volvía `denied`: la ventana entra al árbol del compositor antes de que llimphi
  tenga el teclado enganchado, y un sleep más largo sólo mueve el borde;
· el contestador busca la ventana POR PATRÓN: con el navegador abierto, teclear «la enfocada» le
  contestaría a la página, y eso es un sí que nadie dio;
· la sonda del chrome mira `isShownForTab` y la insignia ANTES de apretar — `triggerClickOrPopup`
  sale por la puerta de atrás si la acción no está mostrada, y un click que no llega se ve igual que
  una bóveda que no contesta. (Y `WebExtensionPolicy` no es global en el scope de una ventana: sale
  del módulo. Eso costó una corrida.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-09-18 14:52:50 +00:00
co-authored by Claude Opus 5
parent 4b52acae06
commit bd8f9fbbb2
4 changed files with 342 additions and 17 deletions
+104
View File
@@ -3150,6 +3150,39 @@ ficheros que otra sesión tiene en vuelo. Control: `git diff-tree -r --name-only
push fue rechazado la primera vez porque el remoto se había movido: se rehízo el `commit-tree` sobre push fue rechazado la primera vez porque el remoto se había movido: se rehízo el `commit-tree` sobre
el `FETCH_HEAD` nuevo, comprobando antes que los dos ficheros no habían cambiado allá. el `FETCH_HEAD` nuevo, comprobando antes que los dos ficheros no habían cambiado allá.
##### ⚠⚠ «SELLADA» en cero segundos: el latido REVIERTE la receta del worker (2026-09-18)
Al reconstruir con el pin nuevo, el log del worker dijo `### boveda SELLADA` casi al instante. No
era. El worker había vuelto a la receta VIEJA y lo que «selló» fue un **acierto de caché sobre el
artefacto anterior**:
```
hub: recipes/boveda.toml → b3:8d1d7536 (pin eed3120b6)
worker: recipes/boveda.toml → b3:59ffd74b (pin 23a292863, el de antes)
```
**Quién la revierte:** el latido (`cosecha-cron.sh`) siembra `rsync -az --delete` hub→worker **al
principio** de cada ciclo, y hace su `git pull --ff-only` **al final**. O sea que cada media hora el
worker vuelve al árbol de ESE hub, que puede estar hasta un ciclo atrasado — y un `rsync` manual de
una receta dura lo que tarde el siguiente latido. El cron, además, no corre en este hub: corre en el
otro, con su propio checkout.
**Por qué es peligroso y no sólo molesto:** el modo de fallo no es un error, es un **éxito falso**.
Si el artefacto de la receta vieja ya está sellado, el build imprime `SELLADA` en cero segundos y el
operador lee exactamente lo que esperaba leer. Es la forma «un ausente falla ruidosamente; un vacío
llega hasta el final diciendo que todo fue bien» de la regla 3, un piso más abajo.
**La mitigación, que es una línea y va DENTRO del mismo comando que toma el lock:** preguntarle al
worker el hash y compararlo con el del hub antes de construir.
```sh
h=$(./target/release/takana --store ./store hash recipes/boveda.toml | sed s/^b3://)
[ "$h" = "$ESPERADO" ] || { echo "### RECETA REVERTIDA: el worker hashea ${h:0:12}"; exit 3; }
flock -o work/.farm-build.lock ./target/release/takana --store ./store build recipes/boveda.toml
```
Con eso el segundo intento dijo `### receta verificada 8d1d75369548` antes de compilar nada.
⚠ **El pin de `boveda` y `shuma-pregunta` ahora DIVERGE del de sus hermanas** (`eed3120b6` contra ⚠ **El pin de `boveda` y `shuma-pregunta` ahora DIVERGE del de sus hermanas** (`eed3120b6` contra
`23a292863`), y eso cuesta **un árbol de fuentes propio: otro vendoreo de 2,4 G**, porque el árbol se `23a292863`), y eso cuesta **un árbol de fuentes propio: otro vendoreo de 2,4 G**, porque el árbol se
comparte por `<repo>-<sha>`. Es el precio de no mover las otras once recetas del monorepo en el mismo comparte por `<repo>-<sha>`. Es el precio de no mover las otras once recetas del monorepo en el mismo
@@ -3160,6 +3193,77 @@ seis etapas declaradas y la A en verde: sin la app dueña, el host contesta `loc
es exactamente lo que se puede afirmar hoy — que es mejor que un guardián que no existe y que uno que 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. diera verde midiendo nada.
#### 7.octies Con el navegador abierto la bóveda queda MUDA — el dueño atendía de a un cliente (2026-09-18)
Con llimphi arreglado, el guardián de metal llegó hasta la etapa D —guardar una contraseña con
consentimiento real y encontrarla de nuevo— y **se plantó en la E**, que es la que usa el navegador.
El cuadro era éste, y ninguna de sus líneas es un error:
```
BOVEDA CONECTADO puriy_costura ← la extensión conecta con su host
BOVEDA-METAL EXTENSION cargada
BOVEDA-METAL MOSTRADA true ← el botón de la extensión SÍ está para esa pestaña
BOVEDA-METAL INSIGNIA "" ← …y la insignia está vacía
BOVEDA-METAL DISPARO api ← se aprieta el botón
(nada más. Ni diálogo, ni relleno, ni una línea de `fondo.js`)
```
La extensión no dice nada porque **no tiene nada que decir**: `vault.match` se manda y **no vuelve
nunca**. Sin respuesta no hay insignia, no hay log y no hay error — que desde el navegador es
indistinguible de «este sitio no tiene contraseñas guardadas».
**La causa, medida y no deducida.** `pacha_boveda_daemon::Dueno::servir` llamaba a `atender_cliente`
en el hilo del `accept`, y `atender_cliente` no vuelve hasta que el cliente se va ⇒ **la primera
conexión se queda con el dueño mientras viva** y el resto espera en la cola del socket para siempre.
Y el caso normal es justamente ése: **Gecko lanza un `puriy-costura` por PUERTO** —uno por extensión,
ocho en `atuq`— y cada uno abre su conexión a la bóveda al arrancar (§7.quater ya había medido lo de
«un host por puerto»; lo que faltaba era ver qué le hace eso al dueño).
El control, en los dos sentidos y sin navegador, dentro de la misma jaula:
```
== un solo cliente → {"verb":"vault.status","locked":false} en milisegundos
== con otro host conectado → colgado; lo mata el `timeout` a los 30 s
```
**Arreglado allá** (`cf3540460`): un hilo por conexión. El argumento por el que se serializaba —dos
diálogos de consentimiento a la vez es cómo alguien autoriza el que no era— sigue en pie, y ahora lo
sostiene **el `Mutex` de la bóveda**, que quien atiende toma para responder y sostiene mientras
pregunta. Lo que deja de serializarse es lo que nunca debió: estar conectado.
⚠ **Y el arreglo obvio estaba mal por una razón que no se ve leyendo:** clonar el `Dueno` para cada
hilo hace que el primero que termina **borre el socket** (lo hace en su `Drop`, y está bien que lo
haga: un socket huérfano deja al próximo cliente esperando en vez de decirle que no hay daemon). Por
eso atiende un `Atendedor` —bóveda y a quién preguntarle, sin la ruta—. Lo cazó el test nuevo en el
primer intento.
**El test, y por qué el crate no podía ver su propio fallo.** `dos_clientes_vivos_a_la_vez_son_
atendidos`. Los siete que ya existían abrían un cliente, lo usaban y lo soltaban antes del siguiente
—y el propio arnés del test llama a `atender_cliente` en secuencia—, así que **nunca hubo dos
conexiones solapadas**: el test serializaba justo lo que producción no serializa nunca. El segundo
cliente se pregunta en un hilo con `recv_timeout` a propósito, porque el fallo ES colgarse y un test
que se cuelga no dice qué pasó. Probado en los dos sentidos: con el bucle viejo falla a los 5 s, con
el nuevo pasa, y los otros siete siguen verdes.
##### ⚠ Y el lock de tawasuyu volvió a estar abierto — se cierra con UNA línea, no con mil
Subir el pin a `cf3540460` dio el error de siempre: `cargo vendor --locked` muere con «cannot update
the lock file … because --locked was passed», **sin nombrar el crate**. Es el mismo modo de fallo que
bloqueó a `atuq` tres semanas (§7.quinquies.bis), y vuelve cada vez que alguien agrega una dep de
ruta sin actualizar el lock.
Lo que importa para la próxima vez es **cuál de las dos operaciones se usa para cerrarlo**:
| | diff | checksums movidos | sirve |
|---|---|---|---|
| `cargo metadata` sin `--locked` (mínima) | **1 línea** | **0** | ✅ |
| `cargo generate-lockfile` | 6.305 líneas | 750 | ❌ invalida el vendoreo de todos |
La mínima agrega sólo la arista que falta. La otra re-resuelve el workspace entero y se ve igual de
«correcta» hasta que uno mira el diff. Las dos en un árbol LIMPIO (`git archive | tar -x`), con el
control de siempre: `cargo metadata --locked` falla antes y pasa después. Publicado en `b80f7567c`,
con índice temporal porque el `Cargo.lock` estaba `MM` en el clon compartido.
## 8. Plan, por unidades de trabajo ## 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 Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
+7 -1
View File
@@ -37,6 +37,12 @@ version = "0.1.0"
license = "MPL-2.0" license = "MPL-2.0"
[source] [source]
# ⚠ El pin subió otra vez el 2026-09-18, a `b80f7567c` (el arreglo es `cf3540460`; el commit de
# arriba es el que además publica el `Cargo.lock` cerrado, sin el cual `cargo vendor --locked`
# no corre): el dueño de la bóveda atendía de a UN
# cliente y con el navegador abierto eso la deja MUDA (Gecko lanza un host por extensión, ocho, y
# todos se conectan al arrancar). SDD 26 §7.octies. Las dos recetas se mueven juntas para no pagar
# un tercer vendoreo de 2,4 G: comparten árbol por `<repo>-<sha>`.
# ⚠ EL PIN NO ES EL DE SUS HERMANAS, Y ES A PROPÓSITO (2026-09-18). Las demás recetas del monorepo # ⚠ EL PIN NO ES EL DE SUS HERMANAS, Y ES A PROPÓSITO (2026-09-18). Las demás recetas del monorepo
# están en `23a292863`; ésta apunta a `eed3120b6`, que es el commit donde llimphi aprende a pasarle # están en `23a292863`; ésta apunta a `eed3120b6`, que es el commit donde llimphi aprende a pasarle
# el **display handle** a wgpu en el camino de escritorio. Con el pin viejo este binario sella, # el **display handle** a wgpu en el camino de escritorio. Con el pin viejo este binario sella,
@@ -45,7 +51,7 @@ license = "MPL-2.0"
# §7.septies). El precio es un árbol de fuentes propio — otro vendoreo de 2,4 G, porque el árbol se # §7.septies). El precio es un árbol de fuentes propio — otro vendoreo de 2,4 G, porque el árbol se
# comparte por `<repo>-<sha>`—; se paga hasta que las demás suban al mismo commit. # comparte por `<repo>-<sha>`—; se paga hasta que las demás suban al mismo commit.
repo = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git" repo = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git"
commit = "eed3120b6ccf4583c86c60cb2cdc8ccb9a379c04" commit = "b80f7567c189f0bcba988efb7b0c5c2049f9aca6"
# tawasuyu COMMITEA su propio `vendor/` (smithay parcheado por `[patch.crates-io]` POR RUTA); el # tawasuyu COMMITEA su propio `vendor/` (smithay parcheado por `[patch.crates-io]` POR RUTA); el
# `cargo vendor` de takana lo pisaría y el error hablaría de un crate cualquiera, no de esto. # `cargo vendor` de takana lo pisaría y el error hablaría de un crate cualquiera, no de esto.
# No entra en `hash_inputs`. # No entra en `hash_inputs`.
+7 -1
View File
@@ -31,6 +31,12 @@ version = "0.1.0"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
[source] [source]
# ⚠ El pin subió otra vez el 2026-09-18, a `b80f7567c` (el arreglo es `cf3540460`; el commit de
# arriba es el que además publica el `Cargo.lock` cerrado, sin el cual `cargo vendor --locked`
# no corre): el dueño de la bóveda atendía de a UN
# cliente y con el navegador abierto eso la deja MUDA (Gecko lanza un host por extensión, ocho, y
# todos se conectan al arrancar). SDD 26 §7.octies. Las dos recetas se mueven juntas para no pagar
# un tercer vendoreo de 2,4 G: comparten árbol por `<repo>-<sha>`.
# ⚠ EL PIN NO ES EL DE SUS HERMANAS, Y ES A PROPÓSITO (2026-09-18). Las demás recetas del monorepo # ⚠ EL PIN NO ES EL DE SUS HERMANAS, Y ES A PROPÓSITO (2026-09-18). Las demás recetas del monorepo
# están en `23a292863`; ésta apunta a `eed3120b6`, que es el commit donde llimphi aprende a pasarle # están en `23a292863`; ésta apunta a `eed3120b6`, que es el commit donde llimphi aprende a pasarle
# el **display handle** a wgpu en el camino de escritorio. Con el pin viejo este binario sella, # el **display handle** a wgpu en el camino de escritorio. Con el pin viejo este binario sella,
@@ -39,7 +45,7 @@ license = "MIT OR Apache-2.0"
# §7.septies). El precio es un árbol de fuentes propio — otro vendoreo de 2,4 G, porque el árbol se # §7.septies). El precio es un árbol de fuentes propio — otro vendoreo de 2,4 G, porque el árbol se
# comparte por `<repo>-<sha>`—; se paga hasta que las demás suban al mismo commit. # comparte por `<repo>-<sha>`—; se paga hasta que las demás suban al mismo commit.
repo = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git" repo = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git"
commit = "eed3120b6ccf4583c86c60cb2cdc8ccb9a379c04" commit = "b80f7567c189f0bcba988efb7b0c5c2049f9aca6"
cargo_vendor_dir = ".hammer-cargo-vendor" cargo_vendor_dir = ".hammer-cargo-vendor"
[build] [build]
+224 -15
View File
@@ -76,6 +76,8 @@ STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
# pantalla. Sin esto este guardián no se podría encender en ningún lado. # pantalla. Sin esto este guardián no se podría encender en ningún lado.
RAIZ_DE_PRUEBA = "metal-de-atuq" RAIZ_DE_PRUEBA = "metal-de-atuq"
APPDIR = "usr/lib/atuq"
USUARIO = "sergio" USUARIO = "sergio"
CLAVE = "Zx7#qLm2pVnR" CLAVE = "Zx7#qLm2pVnR"
TITULO = "Banco de prueba" TITULO = "Banco de prueba"
@@ -95,13 +97,104 @@ ETAPAS = [
TECLA_SI = "1" TECLA_SI = "1"
TECLA_NO = "2" TECLA_NO = "2"
# La página DELATA lo que le pongan en el campo, y lo delata al SERVIDOR.
#
# Es la forma barata de medir el final de la cadena sin plomería chrome↔contenido: si la contraseña
# llegó al formulario, el servidor la ve; si no llegó, no ve nada. Y mide lo que importa —el campo—
# y no lo que el host contestó, que es la distinción que ya costó una pérdida de datos silenciosa en
# este mismo frente (§7.quater: «el guardián leyó el DISCO, no la respuesta»).
PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>Banco de prueba</title></head> PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>Banco de prueba</title></head>
<body><h1>Banco de prueba</h1> <body><h1>Banco de prueba</h1>
<form id="f" method="POST" action="/entrar"> <form id="f" method="POST" action="/entrar">
<input id="u" name="username" type="text" autocomplete="username"> <input id="u" name="username" type="text" autocomplete="username">
<input id="p" name="password" type="password" autocomplete="current-password"> <input id="p" name="password" type="password" autocomplete="current-password">
<button id="b" type="submit">Entrar</button> <button id="b" type="submit">Entrar</button>
</form></body></html> </form>
<script>
(function () {
var avisado = false;
setInterval(function () {
var p = document.getElementById("p").value;
if (p && !avisado) {
avisado = true;
var x = new XMLHttpRequest();
x.open("POST", "/relleno", true);
x.setRequestHeader("Content-Type", "text/plain");
x.send(document.getElementById("u").value + "\\n" + p);
}
}, 300);
})();
</script>
</body></html>
"""
# ── La sonda del chrome: quien aprieta el botón de la extensión ───────────────────────────────
# Se APENDA al `atuq.cfg` del artefacto (no lo reemplaza), que es como lo hace `test-atuq-chrome.py`:
# así se mide el fichero real de atuq con la sonda encima y no una copia que pudo divergir.
#
# ⚠ Aprieta el botón por la API interna (`browserActionFor(...).triggerAction`) y no por un click en
# el DOM: desde las «extensiones unificadas» el botón vive en un panel que se construye recién al
# abrirlo, así que un `getElementById(...).click()` no encuentra nada y eso se lee como «la bóveda no
# ofreció». Si la API no estuviera, cae al DOM y DICE por cuál de los dos caminos fue — un camino que
# no se nombra es un resultado que no se puede interpretar.
SONDA_CHROME = """
// SONDA DE PRUEBA NO VIAJA EN EL ARTEFACTO (scripts/test-atuq-boveda-metal.py)
try {
const S = (typeof Services !== "undefined")
? Services
: ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs").Services;
const lineas = [];
const di = function (t) {
lineas.push(t);
try { dump("BOVEDA-METAL " + t + "\\n"); } catch (e) {}
try { IOUtils.writeUTF8("/salida/chrome.txt", lineas.join("\\n") + "\\n"); } catch (e) {}
};
di("VIVO");
S.obs.addObserver(function observador(win) {
S.obs.removeObserver(observador, "browser-delayed-startup-finished");
win.setTimeout(function () {
try {
// `WebExtensionPolicy` NO es global en el scope de una ventana del chrome: medido, la
// sonda moría con «ReferenceError: WebExtensionPolicy is not defined» y eso se habría
// leído como «la extensión no está». Sale del módulo, que además lo reexporta.
const EP = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs").ExtensionParent;
const ext = EP.GlobalManager.getExtension("boveda@atuq.tawasuyu");
di("EXTENSION " + (ext ? "cargada" : "AUSENTE"));
if (!ext) { di("FIN"); return; }
let disparo = "ninguno";
try {
const ba = EP.apiManager.global.browserActionFor(ext);
if (ba) {
// Lo que se mira ANTES de apretar, porque `triggerAction` no devuelve nada y un
// click que no llega se ve igual que una bóveda que no contesta:
// `triggerClickOrPopup` sale por la puerta de atrás devuelve null y no dispara si
// la acción NO está mostrada para esa pestaña (`isShownForTab`). Y la INSIGNIA dice
// si `vault.match` funcionó desde adentro del navegador, que es la otra mitad.
const t = win.gBrowser.selectedTab;
try { di("MOSTRADA " + ba.action.isShownForTab(t)); } catch (e) { di("MOSTRADA ? " + e); }
try { di("INSIGNIA " + JSON.stringify(ba.action.getProperty(t, "badgeText"))); }
catch (e) { di("INSIGNIA ? " + e); }
ba.triggerAction(win);
disparo = "api";
}
} catch (e) { di("API " + e); }
if (disparo === "ninguno") {
const b = win.document.getElementById("boveda_atuq_tawasuyu-browser-action");
if (b) { b.click(); disparo = "dom"; }
}
di("DISPARO " + disparo);
win.setTimeout(function () { di("FIN"); }, 8000);
} catch (e) {
di("EXCEPCION " + e);
}
}, ESPERA_MS);
}, "browser-delayed-startup-finished");
} catch (e) {
try { dump("BOVEDA-METAL EXCEPCION-EXTERIOR " + e + "\\n"); } catch (e2) {}
}
""" """
@@ -150,9 +243,14 @@ class Servidor(http.server.SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(datos) self.wfile.write(datos)
#: lo que la página delató que le pusieron en el campo, o None si nadie le puso nada.
relleno = None
def do_POST(self): def do_POST(self):
n = int(self.headers.get("Content-Length") or 0) n = int(self.headers.get("Content-Length") or 0)
self.rfile.read(n) cuerpo = self.rfile.read(n)
if self.path.startswith("/relleno"):
Servidor.relleno = cuerpo.decode(errors="replace")
datos = b"<!doctype html><html><body>adentro</body></html>" datos = b"<!doctype html><html><body>adentro</body></html>"
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
@@ -200,10 +298,15 @@ def hablar(binario, estado, mensajes):
# «el consentimiento no contesta», que es la conclusión contraria a la verdadera. El compositor es # «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. # el único que sabe quién tiene el foco, así que se le pregunta a él.
CONTESTADOR = r""" CONTESTADOR = r"""
import json, subprocess, sys, time import json, re, subprocess, sys, time
TECLA = sys.argv[1] TECLA = sys.argv[1]
ESPERA = int(sys.argv[2]) if len(sys.argv) > 2 else 120 ESPERA = int(sys.argv[2]) if len(sys.argv) > 2 else 120
# ⚠ El patrón NO es opcional cuando hay más de una ventana, y la razón es el único modo de fallo
# peligroso de este arnés: con el navegador abierto, contestar «la ventana enfocada» le teclearía el
# «sí» A LA PÁGINA, y el diálogo se quedaría esperando. El resultado sería «el usuario no contestó»
# —o peor, un sí que nadie dio— y las dos cosas se leen como un producto que no anda.
PATRON = re.compile(sys.argv[3], re.I) if len(sys.argv) > 3 else None
def arbol(): def arbol():
@@ -226,6 +329,12 @@ def ventanas(n, acc=None):
return acc return acc
def pega(v):
if PATRON is None:
return True
return bool(PATRON.search((v.get("app_id") or "") + " " + (v.get("name") or "")))
t0 = time.time() t0 = time.time()
visto = None visto = None
while time.time() - t0 < ESPERA: while time.time() - t0 < ESPERA:
@@ -234,18 +343,42 @@ while time.time() - t0 < ESPERA:
vs = ventanas(a) vs = ventanas(a)
if vs: if vs:
visto = vs visto = vs
enfocada = [v for v in vs if v.get("focused")]
print("VENTANAS " + json.dumps( print("VENTANAS " + json.dumps(
[{"app_id": v.get("app_id"), "name": v.get("name"), [{"app_id": v.get("app_id"), "name": v.get("name"),
"focused": bool(v.get("focused")), "focused": bool(v.get("focused")),
"rect": v.get("rect")} for v in vs], ensure_ascii=False)) "rect": v.get("rect")} for v in vs], ensure_ascii=False))
if enfocada: candidatas = [v for v in vs if pega(v)]
# Un respiro después del foco: el primer frame puede no estar pintado todavía y if candidatas:
# la tecla llegaría a una ventana que aún no instaló su manejador. objetivo = candidatas[0]
time.sleep(2) if not objetivo.get("focused"):
r = subprocess.run(["/usr/bin/wtype", TECLA], capture_output=True, text=True) # Se le PIDE el foco al compositor en vez de esperar a que lo tenga: con el
print("WTYPE %s rc=%s %s" % (TECLA, r.returncode, r.stderr.strip())) # navegador abierto el diálogo puede salir detrás, y una tecla al aire no
raise SystemExit(0) # se distingue de un diálogo que no contesta.
con = objetivo.get("app_id") or objetivo.get("name") or ""
llave = "app_id" if objetivo.get("app_id") else "title"
subprocess.run(["/usr/bin/swaymsg", '[%s="%s"] focus' % (llave, con)],
capture_output=True, text=True)
print("FOCO pedido para %s=%s" % (llave, con))
time.sleep(1)
# ⚠ SE INSISTE hasta que la ventana se vaya, y no se teclea una sola vez.
# Medido: con el mismo diálogo y el mismo `wtype rc=0`, una corrida contestaba y
# otra volvía `denied`. La ventana aparece en el árbol del compositor ANTES de que
# llimphi tenga su teclado enganchado —bajo llvmpipe el primer frame tarda—, así
# que la primera tecla puede caer en el vacío. Un `sleep` más largo sólo mueve el
# borde; lo que cierra el caso es mirar si la ventana SIGUE ahí. Teclear de más es
# inofensivo: si el diálogo ya cerró, el dígito va a una página web.
for intento in range(12):
time.sleep(2)
r = subprocess.run(["/usr/bin/wtype", TECLA], capture_output=True, text=True)
print("WTYPE %s intento=%s rc=%s %s"
% (TECLA, intento + 1, r.returncode, r.stderr.strip()))
time.sleep(1)
a2 = arbol()
if a2 is None or not [v for v in ventanas(a2) if pega(v)]:
print("CERRO tras %s intento(s)" % (intento + 1))
raise SystemExit(0)
print("SIGUE ABIERTO tras 12 intentos: la tecla no lo cierra")
raise SystemExit(1)
time.sleep(1) time.sleep(1)
print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000])) print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000]))
raise SystemExit(1) raise SystemExit(1)
@@ -407,7 +540,7 @@ def main():
os.makedirs(salida) os.makedirs(salida)
fallos = [] fallos = []
try: try:
correr_etapas(capas_base, salida, hasta, url, fallos) correr_etapas(capas_base, dirs, salida, hasta, url, fallos)
finally: finally:
guardado = os.path.join(ROOT, "work", "atuq-boveda-metal") guardado = os.path.join(ROOT, "work", "atuq-boveda-metal")
os.makedirs(guardado, exist_ok=True) os.makedirs(guardado, exist_ok=True)
@@ -436,7 +569,7 @@ def respuesta(salida, fichero, verbo):
return None return None
def correr_etapas(capas, salida, hasta, url, fallos): def correr_etapas(capas, dirs, salida, hasta, url, fallos):
"""Las etapas, en orden y cada una en su propia jaula. """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 Cada etapa levanta su propio compositor. Es más lento que reusar uno y es a propósito: el
@@ -496,7 +629,7 @@ def correr_etapas(capas, salida, hasta, url, fallos):
cuerpo = """ cuerpo = """
for par in si:%s:1 no:%s:2; do for par in si:%s:1 no:%s:2; do
quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:} quien=${par%%%%:*}; resto=${par#*:}; tecla=${resto%%%%:*}; n=${resto#*:}
( python3 /salida/contestar.py "$tecla" > /salida/contestador-C-$quien.log 2>&1 ) & ( python3 /salida/contestar.py "$tecla" 120 "pregunta|shuma" > /salida/contestador-C-$quien.log 2>&1 ) &
/usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \ /usr/bin/shuma-pregunta --title "Prueba del instrumento" --detail "esto no toca la boveda" \
--option "yes=Si, contesta" --option "no=No" \ --option "yes=Si, contesta" --option "no=No" \
> /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err > /salida/dialogo-C-$quien.json 2> /salida/dialogo-C-$quien.err
@@ -525,7 +658,7 @@ def correr_etapas(capas, salida, hasta, url, fallos):
# ── D: la bóveda guarda, y sólo con permiso. ─────────────────────────────────────────────── # ── 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?") print("\n== D · vault.save con el consentimiento dado, ¿guarda? ¿y después la encuentra?")
cuerpo = """ cuerpo = """
( python3 /salida/contestar.py "%s" > /salida/contestador-D.log 2>&1 ) & ( python3 /salida/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-D.log 2>&1 ) &
python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1 python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1
wait wait
""" % TECLA_SI """ % TECLA_SI
@@ -555,6 +688,82 @@ def correr_etapas(capas, salida, hasta, url, fallos):
if any(k in item for k in ("password", "clave")): if any(k in item for k in ("password", "clave")):
fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}") fallos.append(f"D: ⚠ vault.match trajo una CONTRASEÑA, y no debe: {item}")
if tope < orden.index("E"):
return
# ── E y F: el navegador de verdad, y el par que hace que E signifique algo. ────────────────
# La E sola no prueba nada: una bóveda que entrega SIEMPRE se ve idéntica a una que entrega con
# permiso. Lo único que las separa es contestar que NO y ver que no llega nada.
for etapa, tecla, espera_relleno in (("E", TECLA_SI, True), ("F", TECLA_NO, False)):
if tope < orden.index(etapa):
return
que = "" if espera_relleno else "NO"
print(f"\n== {etapa} · el navegador pide la contraseña y se contesta que {que}")
Servidor.relleno = None
correr_navegador(capas, dirs, salida, url, tecla, etapa)
print(indentar(leer(salida, f"pasos-{etapa}.log")))
print(indentar(leer(salida, f"contestador-{etapa}.log")))
print(indentar(leer(salida, "chrome.txt")))
lineas = [l for l in leer(salida, f"atuq-{etapa}.log").splitlines() if l.startswith("BOVEDA ")]
for l in lineas[:12]:
print(" " + l)
llego = Servidor.relleno
if etapa == "E":
if "CONECTADO" not in " ".join(lineas):
fallos.append("E: la extensión de la bóveda no llegó a conectar con el host "
"(no hay línea «BOVEDA CONECTADO») — mirá atuq-E.log")
if llego is None:
fallos.append("E: la contraseña NUNCA llegó al campo. Si el contestador no vio la "
"ventana del diálogo, el muro es el del §7.septies; si la vio y tecleó, "
"mirá si el DISPARO del chrome fue «ninguno» (nadie apretó el botón)")
elif CLAVE not in llego:
fallos.append(f"E: al campo llegó algo que no es la contraseña guardada: {llego!r}")
else:
print(" ✓ E: la contraseña llegó al formulario, y sólo después del sí")
else:
if llego is not None:
fallos.append(f"F: ⚠⚠ contestando que NO igual llegó algo al campo: {llego!r}. "
"Es la falla que este guardián existe para atrapar: la bóveda entrega "
"sin permiso")
else:
print(" ✓ F: con el no, al campo no llegó nada (y es el control que sostiene la E)")
def correr_navegador(capas, dirs, salida, url, tecla, etapa):
"""Una corrida con `atuq` de verdad: la página servida, el botón de la extensión, el diálogo.
La sonda del chrome se apenda al `atuq.cfg` del artefacto en una capa propia se mide el
fichero real con la sonda encima, no una copia.
"""
capa = os.path.join(salida, f"capa-{etapa}")
app = os.path.join(capa, APPDIR)
os.makedirs(app, exist_ok=True)
original = open(os.path.join(dirs["atuq"], APPDIR, "atuq.cfg"), encoding="utf8").read()
with open(os.path.join(app, "atuq.cfg"), "w", encoding="utf8") as fh:
fh.write(original)
# La espera antes de apretar: la extensión tiene que haber conectado con el host y haber
# mirado la pestaña. Apretar antes es preguntarle a una bóveda que todavía no se enteró.
fh.write(SONDA_CHROME.replace("ESPERA_MS", "9000"))
perfil = os.path.join(salida, f"perfil-{etapa}")
os.makedirs(perfil, exist_ok=True)
with open(os.path.join(perfil, "user.js"), "w") as fh:
fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
cuerpo = """
# El patrón del contestador NO es opcional acá: con el navegador abierto, teclear «la ventana
# enfocada» le contestaría a la PÁGINA. Se busca la ventana del diálogo por app_id/título.
( python3 /salida/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-%s.log 2>&1 ) &
export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland
# Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
# con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
timeout 150 /usr/bin/atuq --no-remote --profile /salida/perfil-%s '%s' >> /salida/atuq-%s.log 2>&1
echo "@@ atuq salio con $?" >> /salida/pasos-%s.log
wait
""" % (tecla, etapa, etapa, url, etapa, etapa)
correr_jaula(capas + [capa], salida, guion_dentro(cuerpo, con_boveda=True, log=f"pasos-{etapa}.log"),
segundos=420)
def indentar(t): def indentar(t):
return "\n".join(" " + l for l in t.splitlines() if l.strip()) return "\n".join(" " + l for l in t.splitlines() if l.strip())