La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera,
medida de punta a punta con un servidor HTTP real y seis cargas de página:
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
carga 1-3 (mismo script) fase=learning eventos=0 insignia vacía
carga 4 (mismo script) fase=stable eventos=0 insignia vacía
carga 5 (mismo script, estable) fase=stable eventos=0 insignia vacía ← control
carga 6 (script CAMBIADO) fase=stable eventos=1 insignia "1"
EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B
La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, del otro lado
del cable — duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y el
primero es el que está certificado sin red.
CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas:
1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de
`XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko;
2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace
`command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state`
el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el
lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de
un usuario es layout del FHS, o sea asunto de la distro y no del crate;
3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja`
(se le preguntó al artefacto, no a la documentación de Mozilla);
4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una
PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN;
5. ⚠ **una carga de página puede producir dos peticiones del mismo documento, y una llega con
`tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
visitas. No es cosmético: inflar las visitas estabiliza el origen ANTES de conocer su código real,
y entonces alerta por churn legítimo — el falso positivo que la spec de puriy-sct pide evitar por
encima de todo. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error
seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga,
una visita», así que si vuelve, falla ruidoso.
DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable
del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no
bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje entre procesos en el camino
crítico de cada script de cada página no es «más seguro», es un navegador que nadie usa.
EL AVISO SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla,
y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única
parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final.
CONTROL NEGATIVO: `--negative-control` borra el manifiesto y exige que NO haya veredicto — o sea que
el veredicto de la corrida positiva viene del host y no de la extensión inventándolo.
Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada
despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se
cierra.
Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable
dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y
de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las
extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta.
`runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y
el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que
llega a las cuatro imágenes de escritorio.
⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya
decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos
cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni
con el de la v2, que engancha el script loader y ve los bytes reales.
Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre
el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y
la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después
la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre
un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la
del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta.
Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen
matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen
siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`.
342 lines
18 KiB
Python
Executable File
342 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""¿`sct` avisa cuando un sitio ya estable ejecuta código que nadie vio nunca? (SDD 26 §6.1)
|
|
|
|
python3 scripts/test-atuq-sct.py # la cadena entera, seis cargas de página
|
|
python3 scripts/test-atuq-sct.py --negative-control # sin el manifiesto: exige que NO haya veredicto
|
|
|
|
── QUÉ CADENA SE EJERCITA, Y POR QUÉ NINGUNA PIEZA ALCANZA SOLA ──────────────────────────────
|
|
Un servidor HTTP de verdad sirve una página con un `<script src>`; el navegador la carga seis veces;
|
|
la extensión `sct@atuq.tawasuyu` intercepta el cuerpo del script con `filterResponseData`, se lo pasa
|
|
al host nativo por native messaging, y el host —`puriy-costura`, que por dentro es `puriy-sct`—
|
|
hashea con BLAKE3, aprende el conjunto estable del origen y contesta. Son SEIS piezas en cadena:
|
|
|
|
servidor HTTP → filterResponseData → connectNative → manifiesto de /usr/lib/mozilla
|
|
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
|
|
|
|
Y el veredicto no se deduce de ninguna de ellas: sale del **motor**, por el `dump()` de la extensión,
|
|
que es lo que el navegador de verdad va a mostrar en la insignia.
|
|
|
|
── LAS TRES AFIRMACIONES, Y LAS DOS QUE SON CONTROLES ────────────────────────────────────────
|
|
cargas 1-3 con el MISMO script ⇒ fase `learning`, CERO eventos
|
|
carga 4 con el MISMO script ⇒ fase `stable` (racha 3 de la Config por defecto)
|
|
carga 5 con el MISMO script, YA estable ⇒ CERO eventos ← control: no alerta por cargar
|
|
carga 6 con el script CAMBIADO ⇒ UN evento, con el hash viejo, el nuevo y el delta
|
|
|
|
Las cargas 5 y 6 son el par que hace que la prueba signifique algo: sin la 5, un detector que
|
|
alertara en cada carga se vería idéntico a uno que funciona. Y `--negative-control` va más al hueso:
|
|
borra el manifiesto de `/usr/lib/mozilla/native-messaging-hosts/` y exige que NO haya veredicto —o
|
|
sea que el veredicto de la corrida positiva viene del host y no de la extensión inventándolo—.
|
|
|
|
── Y EL ESTADO QUEDA EN DISCO, QUE ES LO QUE HACE QUE ESTO PROTEJA ───────────────────────────
|
|
El perfil y el `$HOME` se reusan entre las seis corridas, así que el navegador se ABRE Y SE CIERRA
|
|
seis veces: si el host no persistiera, cada arranque volvería a `learning` y la carga 6 no alertaría
|
|
nunca. Al final se le pregunta al propio binario del host, desde afuera, por el estado del origen.
|
|
|
|
REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
|
|
"""
|
|
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/atuq-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"))
|
|
MANIFIESTO = "/usr/lib/mozilla/native-messaging-hosts/puriy_costura.json"
|
|
|
|
SCRIPT_ORIGINAL = "window.__atuq_sct = 1;\n"
|
|
SCRIPT_CAMBIADO = "window.__atuq_sct = 1; /* esto no lo vio nadie */\n"
|
|
PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>sct</title>
|
|
<script src="/app.js"></script></head><body>sct</body></html>
|
|
"""
|
|
|
|
|
|
def fatal(msg):
|
|
print(f"✗ {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def artefacto(receta, nombre, escotilla=None):
|
|
"""El artefacto VIGENTE, por `takana hash` y nunca por glob.
|
|
|
|
⚠ La escotilla (`ATUQ_DIR`) grita: el corpus es compartido y otro frente puede re-hashear
|
|
`firefox`, dejando el atuq vigente sin sellar durante las horas de su build. Un resultado
|
|
obtenido con la escotilla NO se cita como «el atuq 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")
|
|
return d
|
|
|
|
|
|
class Silencioso(http.server.SimpleHTTPRequestHandler):
|
|
"""Sirve la página y el script desde memoria. El cuerpo del script lo cambia el test entre
|
|
cargas, que es justo lo que `sct` tiene que notar."""
|
|
|
|
cuerpo_script = SCRIPT_ORIGINAL
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/app.js"):
|
|
datos, tipo = Silencioso.cuerpo_script.encode(), "application/javascript"
|
|
else:
|
|
datos, tipo = PAGINA.encode(), "text/html"
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", tipo)
|
|
self.send_header("Content-Length", str(len(datos)))
|
|
# Sin caché: si el navegador reusa el script de su caché, la carga 6 no vería el cambio y la
|
|
# prueba diría «no alerta» por una razón que no tiene nada que ver con sct.
|
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
self.end_headers()
|
|
self.wfile.write(datos)
|
|
|
|
|
|
def puerto_libre():
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
p = s.getsockname()[1]
|
|
s.close()
|
|
return p
|
|
|
|
|
|
def correr(atuq, costura, salida, capa, url, negativo):
|
|
"""Una carga de página: abre atuq headless con la URL y devuelve las líneas `SCT`."""
|
|
borrar = f"rm -f {MANIFIESTO}" if negativo else "true"
|
|
guion = f"""
|
|
mkdir -p /root /salida/hogar
|
|
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
|
|
{borrar}
|
|
export HOME=/salida/hogar
|
|
timeout 40 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
|
|
> /salida/consola.log 2>&1
|
|
exit 0
|
|
"""
|
|
subprocess.run([
|
|
"bwrap",
|
|
"--overlay-src", ALPINE, "--overlay-src", RFS,
|
|
"--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", capa,
|
|
"--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",
|
|
"--setenv", "XDG_RUNTIME_DIR", "/run",
|
|
"--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
|
|
"--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
|
|
"sh", "-c", guion,
|
|
], check=False)
|
|
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
|
|
return [l[len("SCT "):] for l in log.splitlines() if l.startswith("SCT ")], log
|
|
|
|
|
|
def preguntarle_al_host(costura, estado):
|
|
"""Le pregunta al binario del host, DESDE AFUERA de la jaula, por el estado del origen. Es la
|
|
evidencia de que lo que el navegador aprendió quedó en disco y no en la memoria de un proceso."""
|
|
binario = os.path.join(costura, "usr/bin/puriy-costura")
|
|
peticion = json.dumps({"verb": "sct.state", "url": "http://127.0.0.1/"}).encode()
|
|
entrada = struct.pack("<I", len(peticion)) + peticion
|
|
r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
|
|
if len(r.stdout) < 4:
|
|
return None
|
|
n = struct.unpack("<I", r.stdout[:4])[0]
|
|
return json.loads(r.stdout[4:4 + n])
|
|
|
|
|
|
def main():
|
|
negativo = "--negative-control" in sys.argv[1:]
|
|
if negativo:
|
|
print("== MODO CONTROL NEGATIVO: se borra el manifiesto del host; NO debe haber veredicto")
|
|
if not os.path.isdir(RFS):
|
|
fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
|
|
atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
|
|
costura = artefacto("puriy-costura", "puriy-costura")
|
|
print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
|
|
|
|
puerto = puerto_libre()
|
|
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Silencioso)
|
|
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
|
url = f"http://127.0.0.1:{puerto}/pagina.html"
|
|
|
|
tmp = tempfile.mkdtemp(prefix="atuq-sct-")
|
|
salida = os.path.join(tmp, "salida")
|
|
capa = os.path.join(tmp, "capa")
|
|
os.makedirs(os.path.join(salida, "perfil"))
|
|
os.makedirs(capa)
|
|
try:
|
|
with open(os.path.join(salida, "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')
|
|
|
|
cargas = []
|
|
for i in range(1, 7):
|
|
Silencioso.cuerpo_script = SCRIPT_CAMBIADO if i == 6 else SCRIPT_ORIGINAL
|
|
lineas, log = correr(atuq, costura, salida, capa, url, negativo)
|
|
cargas.append(lineas)
|
|
marca = " (script CAMBIADO)" if i == 6 else ""
|
|
print(f"\n-- carga {i}{marca}")
|
|
for l in lineas:
|
|
print(" " + l)
|
|
if not lineas and not negativo:
|
|
destino = os.path.join(ROOT, "work", "atuq-sct.log")
|
|
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
|
open(destino, "w").write(log)
|
|
fatal(f"la extensión no dijo NADA en la carga {i} — log en {destino}")
|
|
|
|
def veredicto(lineas):
|
|
for l in lineas:
|
|
if l.startswith("VEREDICTO "):
|
|
campos = dict(p.split("=", 1) for p in l.split() if "=" in p)
|
|
return campos
|
|
return None
|
|
|
|
def eventos(lineas):
|
|
return [l for l in lineas if l.startswith("EVENTO ")]
|
|
|
|
def tandas_de(lineas):
|
|
return [l for l in lineas if l.startswith("TANDA ")]
|
|
|
|
def insignia(lineas):
|
|
for l in lineas:
|
|
if l.startswith("INSIGNIA tab="):
|
|
return l.split("texto=", 1)[1] if "texto=" in l else None
|
|
return None
|
|
|
|
if negativo:
|
|
hubo = [i + 1 for i, c in enumerate(cargas) if veredicto(c)]
|
|
if hubo:
|
|
fatal(f"sin el manifiesto hubo veredicto en las cargas {hubo} — entonces el veredicto"
|
|
" de la corrida positiva NO viene del host")
|
|
desconexion = any(l.startswith("DESCONECTADO") or l.startswith("SIN HOST")
|
|
for c in cargas for l in c)
|
|
if not desconexion:
|
|
fatal("sin el manifiesto la extensión no se quejó: ¿está corriendo?")
|
|
print("\n✓ control negativo: sin el manifiesto no hay veredicto, y la extensión lo dice"
|
|
" (el veredicto sale del host, no de la extensión)")
|
|
return 0
|
|
|
|
# ── LAS ASERCIONES VAN SOBRE LA SECUENCIA OBSERVADA, NO SOBRE UN CALENDARIO ──────────
|
|
# Dos hechos medidos el 2026-09-10, y los dos hacen que «carga N ⇒ visita N» sea falso:
|
|
#
|
|
# 1. **Una carga puede producir DOS tandas.** Hay peticiones del mismo documento que llegan con
|
|
# `tabId = -1`, y la primera versión de la extensión agrupaba por `(tabId, documento)`. Cada
|
|
# tanda es una visita, e inflar las visitas estabiliza el origen ANTES de conocer su código
|
|
# real ⇒ alerta por churn legítimo. Arreglado agrupando por documento; se vigila abajo,
|
|
# porque el síntoma sin esto se lee como «el contador va raro».
|
|
# 2. **La página que se abre EN EL ARRANQUE puede no ser observada.** Compite con la
|
|
# inicialización de la extensión, y a veces gana. En uso real sólo afecta a la página que
|
|
# abre el navegador al lanzarse (después la extensión ya está escuchando), así que no se
|
|
# exige que TODA carga se observe: se exige que ninguna se observe DOS veces, y que la
|
|
# secuencia observada sea la correcta.
|
|
for i, c in enumerate(cargas, start=1):
|
|
n = len(tandas_de(c))
|
|
if n > 1:
|
|
fatal(f"carga {i}: produjo {n} tandas y cada tanda es una VISITA para el TOFU"
|
|
f" — contar visitas de más estabiliza el origen antes de tiempo: {tandas_de(c)}")
|
|
|
|
vistas = [(i + 1, veredicto(c), eventos(c), insignia(c))
|
|
for i, c in enumerate(cargas) if veredicto(c)]
|
|
perdidas = [i + 1 for i, c in enumerate(cargas) if not veredicto(c)]
|
|
if perdidas:
|
|
print(f"\n(cargas sin observar: {perdidas} — la del arranque compite con la"
|
|
" inicialización de la extensión)")
|
|
if perdidas and perdidas != [1]:
|
|
fatal(f"se perdieron cargas que no son la del arranque: {perdidas}")
|
|
if len(vistas) < 5:
|
|
fatal(f"sólo {len(vistas)} de 6 cargas se observaron: con menos de cinco no se puede"
|
|
" afirmar la secuencia aprender→estabilizar→alertar")
|
|
if vistas[-1][0] != 6:
|
|
fatal("la carga del script CAMBIADO (la 6ª) no se observó — sin eso la prueba no"
|
|
" concluye nada")
|
|
|
|
# Una carga observada = una visita: el número de visita tiene que seguir a la posición en la
|
|
# secuencia, sin saltos. Un salto es la señal de la tanda duplicada.
|
|
for pos, (nro, v, _, _) in enumerate(vistas, start=1):
|
|
if v.get("visitas") != str(pos):
|
|
fatal(f"carga {nro}: es la observación nº {pos} y el host contó"
|
|
f" visitas={v.get('visitas')} — una carga observada tiene que ser una visita")
|
|
|
|
for pos in (1, 2, 3):
|
|
nro, v, ev, ins = vistas[pos - 1]
|
|
if v.get("fase") != "learning":
|
|
fatal(f"carga {nro} (observación {pos}): se esperaba fase=learning y salió {v}")
|
|
if ev:
|
|
fatal(f"carga {nro}: alertó mientras el origen APRENDÍA — eso es un falso positivo")
|
|
nro4, v4, ev4, _ = vistas[3]
|
|
if v4.get("fase") != "stable":
|
|
fatal(f"carga {nro4} (observación 4): se esperaba fase=stable (racha 3) y salió {v4}")
|
|
if ev4:
|
|
fatal(f"carga {nro4}: alertó en la observación que ESTABILIZA el origen: {ev4}")
|
|
|
|
# La anterior a la del cambio: mismo script, ya estable ⇒ CERO eventos. Es el control que hace
|
|
# que la última signifique algo: sin él, un detector que alertara en cada carga se vería igual.
|
|
nroA, vA, evA, insA = vistas[-2]
|
|
if evA:
|
|
fatal(f"carga {nroA}: alertó con el MISMO script ya estable — alertaría en cada carga, y"
|
|
" un detector que grita todos los días no se mira más")
|
|
if vA.get("fase") != "stable":
|
|
fatal(f"carga {nroA}: se esperaba fase=stable antes del cambio y salió {vA}")
|
|
if insA != '""':
|
|
fatal(f"carga {nroA}: la insignia quedó en {insA} sin que hubiera novedad")
|
|
|
|
# Y la del script cambiado: UN evento, que nombra el script, y la insignia que lo dice.
|
|
nroZ, vZ, evZ, insZ = vistas[-1]
|
|
if len(evZ) != 1:
|
|
fatal(f"carga {nroZ}: con el script cambiado se esperaba UN evento y hubo"
|
|
f" {len(evZ)}: {evZ}")
|
|
if "app.js" not in evZ[0]:
|
|
fatal(f"carga {nroZ}: el evento no nombra el script: {evZ[0]}")
|
|
# ── LA INSIGNIA, QUE ES LO ÚNICO QUE EL USUARIO VE DE TODA ESTA CADENA ────────────────
|
|
# La extensión la RELEE con `getBadgeText` después de ponerla, así que esto no es «se llamó a
|
|
# la API»: es lo que quedó puesto. Sin medirla, el aviso sería la única pieza sin verificar.
|
|
if insZ != '"1"':
|
|
fatal(f"carga {nroZ}: la insignia quedó en {insZ}, se esperaba \"1\" — el evento no"
|
|
" llegó a la única parte que el usuario ve")
|
|
|
|
estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
|
|
ficheros = sorted(os.listdir(estado)) if os.path.isdir(estado) else []
|
|
if "registro.postcard" not in ficheros or "bitacora.postcard" not in ficheros:
|
|
fatal(f"el host no dejó su estado en disco (hay {ficheros}) — sin eso, cada arranque del"
|
|
" navegador volvería a «aprendiendo» y nada alertaría nunca")
|
|
print(f"\nestado en disco: {ficheros}")
|
|
|
|
print("\n✓ la cadena entera anda: el navegador ejercita `filterResponseData` →"
|
|
" native messaging → puriy-costura → puriy-sct")
|
|
print("✓ una carga observada = una visita (el invariante que rompía agrupar por tabId)")
|
|
print("✓ y la insignia dice lo que tiene que decir: vacía en las cinco, «1» en la sexta")
|
|
print("✓ aprende tres cargas, se estabiliza en la cuarta, NO alerta en la quinta con el"
|
|
" mismo código, y alerta en la sexta con el código cambiado")
|
|
print("✓ y el aprendizaje sobrevive a cerrar el navegador seis veces: el estado está en disco")
|
|
return 0
|
|
finally:
|
|
servidor.shutdown()
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
sys.exit(main())
|