diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index 45bc50fd..dd0ac53e 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -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 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 `23a292863`), y eso cuesta **un árbol de fuentes propio: otro vendoreo de 2,4 G**, porque el árbol se comparte por `-`. 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 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 Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la diff --git a/recipes/boveda.toml b/recipes/boveda.toml index cb3eed12..8eb9478e 100644 --- a/recipes/boveda.toml +++ b/recipes/boveda.toml @@ -37,6 +37,12 @@ version = "0.1.0" license = "MPL-2.0" [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 `-`. # ⚠ 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 # 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 # comparte por `-`—; se paga hasta que las demás suban al mismo commit. 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 # `cargo vendor` de takana lo pisaría y el error hablaría de un crate cualquiera, no de esto. # No entra en `hash_inputs`. diff --git a/recipes/shuma-pregunta.toml b/recipes/shuma-pregunta.toml index 03d1b1ab..4f59a491 100644 --- a/recipes/shuma-pregunta.toml +++ b/recipes/shuma-pregunta.toml @@ -31,6 +31,12 @@ version = "0.1.0" license = "MIT OR Apache-2.0" [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 `-`. # ⚠ 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 # 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 # comparte por `-`—; se paga hasta que las demás suban al mismo commit. repo = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git" -commit = "eed3120b6ccf4583c86c60cb2cdc8ccb9a379c04" +commit = "b80f7567c189f0bcba988efb7b0c5c2049f9aca6" cargo_vendor_dir = ".hammer-cargo-vendor" [build] diff --git a/scripts/test-atuq-boveda-metal.py b/scripts/test-atuq-boveda-metal.py index 91b82c2a..233fa92f 100644 --- a/scripts/test-atuq-boveda-metal.py +++ b/scripts/test-atuq-boveda-metal.py @@ -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. RAIZ_DE_PRUEBA = "metal-de-atuq" +APPDIR = "usr/lib/atuq" + USUARIO = "sergio" CLAVE = "Zx7#qLm2pVnR" TITULO = "Banco de prueba" @@ -95,13 +97,104 @@ ETAPAS = [ TECLA_SI = "1" 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 = """Banco de prueba

Banco de prueba

-
+ + + +""" + + + +# ── 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.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): 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"adentro" self.send_response(200) 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 único que sabe quién tiene el foco, así que se le pregunta a él. CONTESTADOR = r""" -import json, subprocess, sys, time +import json, re, subprocess, sys, time TECLA = sys.argv[1] 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(): @@ -226,6 +329,12 @@ def ventanas(n, acc=None): 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() visto = None while time.time() - t0 < ESPERA: @@ -234,18 +343,42 @@ while time.time() - t0 < ESPERA: 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) + candidatas = [v for v in vs if pega(v)] + if candidatas: + objetivo = candidatas[0] + if not objetivo.get("focused"): + # Se le PIDE el foco al compositor en vez de esperar a que lo tenga: con el + # navegador abierto el diálogo puede salir detrás, y una tecla al aire no + # 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) print("SIN VENTANA tras %ss; lo último que vi: %s" % (ESPERA, json.dumps(visto, ensure_ascii=False)[:2000])) raise SystemExit(1) @@ -407,7 +540,7 @@ def main(): os.makedirs(salida) fallos = [] try: - correr_etapas(capas_base, salida, hasta, url, fallos) + correr_etapas(capas_base, dirs, salida, hasta, url, fallos) finally: guardado = os.path.join(ROOT, "work", "atuq-boveda-metal") os.makedirs(guardado, exist_ok=True) @@ -436,7 +569,7 @@ def respuesta(salida, fichero, verbo): 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. 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 = """ 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 ) & + ( 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" \ --option "yes=Si, contesta" --option "no=No" \ > /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. ─────────────────────────────────────────────── 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/contestar.py "%s" 150 "pregunta|shuma" > /salida/contestador-D.log 2>&1 ) & python3 /salida/preguntar.py guardar >> /salida/pasos-D.log 2>&1 wait """ % TECLA_SI @@ -555,6 +688,82 @@ def correr_etapas(capas, salida, hasta, url, fallos): if any(k in item for k in ("password", "clave")): 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 = "SÍ" 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): return "\n".join(" " + l for l in t.splitlines() if l.strip())