sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ ff78b3d54fb9786c53bf223dde4d428469948289
scripts/test-atuq-sct.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
atuq: `sct` v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
1#!/usr/bin/env python3
2"""¿`sct` avisa cuando un sitio ya estable ejecuta código que nadie vio nunca? (SDD 26 §6.1)
3
4 python3 scripts/test-atuq-sct.py # la cadena entera, seis cargas de página
5 python3 scripts/test-atuq-sct.py --negative-control # sin el manifiesto: exige que NO haya veredicto
6
7── QUÉ CADENA SE EJERCITA, Y POR QUÉ NINGUNA PIEZA ALCANZA SOLA ──────────────────────────────
8Un servidor HTTP de verdad sirve una página con un `<script src>`; el navegador la carga seis veces;
9la extensión `sct@atuq.tawasuyu` intercepta el cuerpo del script con `filterResponseData`, se lo pasa
10al host nativo por native messaging, y el host —`puriy-costura`, que por dentro es `puriy-sct`—
11hashea con BLAKE3, aprende el conjunto estable del origen y contesta. Son SEIS piezas en cadena:
12
13 servidor HTTP → filterResponseData → connectNative → manifiesto de /usr/lib/mozilla
14 → puriy-costura --state → puriy-sct (TOFU + bitácora)
15
16Y el veredicto no se deduce de ninguna de ellas: sale del **motor**, por el `dump()` de la extensión,
17que es lo que el navegador de verdad va a mostrar en la insignia.
18
19── LAS TRES AFIRMACIONES, Y LAS DOS QUE SON CONTROLES ────────────────────────────────────────
20 cargas 1-3 con el MISMO script ⇒ fase `learning`, CERO eventos
21 carga 4 con el MISMO script ⇒ fase `stable` (racha 3 de la Config por defecto)
22 carga 5 con el MISMO script, YA estable ⇒ CERO eventos ← control: no alerta por cargar
23 carga 6 con el script CAMBIADO ⇒ UN evento, con el hash viejo, el nuevo y el delta
24
25Las cargas 5 y 6 son el par que hace que la prueba signifique algo: sin la 5, un detector que
26alertara en cada carga se vería idéntico a uno que funciona. Y `--negative-control` va más al hueso:
27borra el manifiesto de `/usr/lib/mozilla/native-messaging-hosts/` y exige que NO haya veredicto —o
28sea que el veredicto de la corrida positiva viene del host y no de la extensión inventándolo—.
29
30── Y EL ESTADO QUEDA EN DISCO, QUE ES LO QUE HACE QUE ESTO PROTEJA ───────────────────────────
31El perfil y el `$HOME` se reusan entre las seis corridas, así que el navegador se ABRE Y SE CIERRA
32seis veces: si el host no persistiera, cada arranque volvería a `learning` y la carga 6 no alertaría
33nunca. Al final se le pregunta al propio binario del host, desde afuera, por el estado del origen.
34
35REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
36"""
37import http.server
38import json
39import os
40import shutil
41import socket
42import struct
43import subprocess
44import sys
45import tempfile
46import threading
47
48ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
50ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
51HAMMER = os.path.join(ROOT, "target/release/takana")
52STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
53MANIFIESTO = "/usr/lib/mozilla/native-messaging-hosts/puriy_costura.json"
54
55SCRIPT_ORIGINAL = "window.__atuq_sct = 1;\n"
56SCRIPT_CAMBIADO = "window.__atuq_sct = 1; /* esto no lo vio nadie */\n"
57PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>sct</title>
58<script src="/app.js"></script></head><body>sct</body></html>
59"""
60
61
62def fatal(msg):
63 print(f"✗ {msg}", file=sys.stderr)
64 raise SystemExit(1)
65
66
67def artefacto(receta, nombre, escotilla=None):
68 """El artefacto VIGENTE, por `takana hash` y nunca por glob.
69
70 ⚠ La escotilla (`ATUQ_DIR`) grita: el corpus es compartido y otro frente puede re-hashear
71 `firefox`, dejando el atuq vigente sin sellar durante las horas de su build. Un resultado
72 obtenido con la escotilla NO se cita como «el atuq de hoy pasa».
73 """
74 forzado = os.environ.get(escotilla) if escotilla else None
75 if forzado:
76 if not os.path.isdir(forzado):
77 fatal(f"{escotilla}={forzado} no existe")
78 print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
79 return forzado
80 r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
81 cwd=ROOT, capture_output=True, text=True)
82 if r.returncode != 0:
83 fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
84 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
85 d = os.path.join(STORE, f"{h}-{nombre}")
86 if not os.path.isdir(d):
87 fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
88 return d
89
90
91class Silencioso(http.server.SimpleHTTPRequestHandler):
92 """Sirve la página y el script desde memoria. El cuerpo del script lo cambia el test entre
93 cargas, que es justo lo que `sct` tiene que notar."""
94
95 cuerpo_script = SCRIPT_ORIGINAL
96
97 def log_message(self, *a):
98 pass
99
100 def do_GET(self):
101 if self.path.startswith("/app.js"):
102 datos, tipo = Silencioso.cuerpo_script.encode(), "application/javascript"
103 else:
104 datos, tipo = PAGINA.encode(), "text/html"
105 self.send_response(200)
106 self.send_header("Content-Type", tipo)
107 self.send_header("Content-Length", str(len(datos)))
108 # Sin caché: si el navegador reusa el script de su caché, la carga 6 no vería el cambio y la
109 # prueba diría «no alerta» por una razón que no tiene nada que ver con sct.
110 self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
111 self.end_headers()
112 self.wfile.write(datos)
113
114
115def puerto_libre():
116 s = socket.socket()
117 s.bind(("127.0.0.1", 0))
118 p = s.getsockname()[1]
119 s.close()
120 return p
121
122
123def correr(atuq, costura, salida, capa, url, negativo):
124 """Una carga de página: abre atuq headless con la URL y devuelve las líneas `SCT`."""
125 borrar = f"rm -f {MANIFIESTO}" if negativo else "true"
126 guion = f"""
127 mkdir -p /root /salida/hogar
128 [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
129 {borrar}
130 export HOME=/salida/hogar
131 timeout 40 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
132 > /salida/consola.log 2>&1
133 exit 0
134 """
135 subprocess.run([
136 "bwrap",
137 "--overlay-src", ALPINE, "--overlay-src", RFS,
138 "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", capa,
139 "--tmp-overlay", "/",
140 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
141 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
142 "--bind", salida, "/salida",
143 "--uid", "0", "--gid", "0",
144 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
145 "--setenv", "XDG_RUNTIME_DIR", "/run",
146 "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
147 "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
148 "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
149 "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
150 "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
151 "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
152 "sh", "-c", guion,
153 ], check=False)
154 log = open(os.path.join(salida, "consola.log"), errors="replace").read()
155 return [l[len("SCT "):] for l in log.splitlines() if l.startswith("SCT ")], log
156
157
158def preguntarle_al_host(costura, estado):
159 """Le pregunta al binario del host, DESDE AFUERA de la jaula, por el estado del origen. Es la
160 evidencia de que lo que el navegador aprendió quedó en disco y no en la memoria de un proceso."""
161 binario = os.path.join(costura, "usr/bin/puriy-costura")
162 peticion = json.dumps({"verb": "sct.state", "url": "http://127.0.0.1/"}).encode()
163 entrada = struct.pack("<I", len(peticion)) + peticion
164 r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
165 if len(r.stdout) < 4:
166 return None
167 n = struct.unpack("<I", r.stdout[:4])[0]
168 return json.loads(r.stdout[4:4 + n])
169
170
171def main():
172 negativo = "--negative-control" in sys.argv[1:]
173 if negativo:
174 print("== MODO CONTROL NEGATIVO: se borra el manifiesto del host; NO debe haber veredicto")
175 if not os.path.isdir(RFS):
176 fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
177 atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
178 costura = artefacto("puriy-costura", "puriy-costura")
179 print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
180
181 puerto = puerto_libre()
182 servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Silencioso)
183 threading.Thread(target=servidor.serve_forever, daemon=True).start()
184 url = f"http://127.0.0.1:{puerto}/pagina.html"
185
186 tmp = tempfile.mkdtemp(prefix="atuq-sct-")
187 salida = os.path.join(tmp, "salida")
188 capa = os.path.join(tmp, "capa")
189 os.makedirs(os.path.join(salida, "perfil"))
190 os.makedirs(capa)
191 try:
192 with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
193 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
194 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
195
196 cargas = []
197 for i in range(1, 7):
198 Silencioso.cuerpo_script = SCRIPT_CAMBIADO if i == 6 else SCRIPT_ORIGINAL
199 lineas, log = correr(atuq, costura, salida, capa, url, negativo)
200 cargas.append(lineas)
201 marca = " (script CAMBIADO)" if i == 6 else ""
202 print(f"\n-- carga {i}{marca}")
203 for l in lineas:
204 print(" " + l)
205 if not lineas and not negativo:
206 destino = os.path.join(ROOT, "work", "atuq-sct.log")
207 os.makedirs(os.path.dirname(destino), exist_ok=True)
208 open(destino, "w").write(log)
209 fatal(f"la extensión no dijo NADA en la carga {i} — log en {destino}")
210
211 def veredicto(lineas):
212 for l in lineas:
213 if l.startswith("VEREDICTO "):
214 campos = dict(p.split("=", 1) for p in l.split() if "=" in p)
215 return campos
216 return None
217
218 def eventos(lineas):
219 return [l for l in lineas if l.startswith("EVENTO ")]
220
221 def tandas_de(lineas):
222 return [l for l in lineas if l.startswith("TANDA ")]
223
224 def insignia(lineas):
225 for l in lineas:
226 if l.startswith("INSIGNIA tab="):
227 return l.split("texto=", 1)[1] if "texto=" in l else None
228 return None
229
230 if negativo:
231 hubo = [i + 1 for i, c in enumerate(cargas) if veredicto(c)]
232 if hubo:
233 fatal(f"sin el manifiesto hubo veredicto en las cargas {hubo} — entonces el veredicto"
234 " de la corrida positiva NO viene del host")
235 desconexion = any(l.startswith("DESCONECTADO") or l.startswith("SIN HOST")
236 for c in cargas for l in c)
237 if not desconexion:
238 fatal("sin el manifiesto la extensión no se quejó: ¿está corriendo?")
239 print("\n✓ control negativo: sin el manifiesto no hay veredicto, y la extensión lo dice"
240 " (el veredicto sale del host, no de la extensión)")
241 return 0
242
243 # ── LAS ASERCIONES VAN SOBRE LA SECUENCIA OBSERVADA, NO SOBRE UN CALENDARIO ──────────
244 # Dos hechos medidos el 2026-09-10, y los dos hacen que «carga N ⇒ visita N» sea falso:
245 #
246 # 1. **Una carga puede producir DOS tandas.** Hay peticiones del mismo documento que llegan con
247 # `tabId = -1`, y la primera versión de la extensión agrupaba por `(tabId, documento)`. Cada
248 # tanda es una visita, e inflar las visitas estabiliza el origen ANTES de conocer su código
249 # real ⇒ alerta por churn legítimo. Arreglado agrupando por documento; se vigila abajo,
250 # porque el síntoma sin esto se lee como «el contador va raro».
251 # 2. **La página que se abre EN EL ARRANQUE puede no ser observada.** Compite con la
252 # inicialización de la extensión, y a veces gana. En uso real sólo afecta a la página que
253 # abre el navegador al lanzarse (después la extensión ya está escuchando), así que no se
254 # exige que TODA carga se observe: se exige que ninguna se observe DOS veces, y que la
255 # secuencia observada sea la correcta.
256 for i, c in enumerate(cargas, start=1):
257 n = len(tandas_de(c))
258 if n > 1:
259 fatal(f"carga {i}: produjo {n} tandas y cada tanda es una VISITA para el TOFU"
260 f" — contar visitas de más estabiliza el origen antes de tiempo: {tandas_de(c)}")
261
262 vistas = [(i + 1, veredicto(c), eventos(c), insignia(c))
263 for i, c in enumerate(cargas) if veredicto(c)]
264 perdidas = [i + 1 for i, c in enumerate(cargas) if not veredicto(c)]
265 if perdidas:
266 print(f"\n(cargas sin observar: {perdidas} — la del arranque compite con la"
267 " inicialización de la extensión)")
268 if perdidas and perdidas != [1]:
269 fatal(f"se perdieron cargas que no son la del arranque: {perdidas}")
270 if len(vistas) < 5:
271 fatal(f"sólo {len(vistas)} de 6 cargas se observaron: con menos de cinco no se puede"
272 " afirmar la secuencia aprender→estabilizar→alertar")
273 if vistas[-1][0] != 6:
274 fatal("la carga del script CAMBIADO (la 6ª) no se observó — sin eso la prueba no"
275 " concluye nada")
276
277 # Una carga observada = una visita: el número de visita tiene que seguir a la posición en la
278 # secuencia, sin saltos. Un salto es la señal de la tanda duplicada.
279 for pos, (nro, v, _, _) in enumerate(vistas, start=1):
280 if v.get("visitas") != str(pos):
281 fatal(f"carga {nro}: es la observación nº {pos} y el host contó"
282 f" visitas={v.get('visitas')} — una carga observada tiene que ser una visita")
283
284 for pos in (1, 2, 3):
285 nro, v, ev, ins = vistas[pos - 1]
286 if v.get("fase") != "learning":
287 fatal(f"carga {nro} (observación {pos}): se esperaba fase=learning y salió {v}")
288 if ev:
289 fatal(f"carga {nro}: alertó mientras el origen APRENDÍA — eso es un falso positivo")
290 nro4, v4, ev4, _ = vistas[3]
291 if v4.get("fase") != "stable":
292 fatal(f"carga {nro4} (observación 4): se esperaba fase=stable (racha 3) y salió {v4}")
293 if ev4:
294 fatal(f"carga {nro4}: alertó en la observación que ESTABILIZA el origen: {ev4}")
295
296 # La anterior a la del cambio: mismo script, ya estable ⇒ CERO eventos. Es el control que hace
297 # que la última signifique algo: sin él, un detector que alertara en cada carga se vería igual.
298 nroA, vA, evA, insA = vistas[-2]
299 if evA:
300 fatal(f"carga {nroA}: alertó con el MISMO script ya estable — alertaría en cada carga, y"
301 " un detector que grita todos los días no se mira más")
302 if vA.get("fase") != "stable":
303 fatal(f"carga {nroA}: se esperaba fase=stable antes del cambio y salió {vA}")
304 if insA != '""':
305 fatal(f"carga {nroA}: la insignia quedó en {insA} sin que hubiera novedad")
306
307 # Y la del script cambiado: UN evento, que nombra el script, y la insignia que lo dice.
308 nroZ, vZ, evZ, insZ = vistas[-1]
309 if len(evZ) != 1:
310 fatal(f"carga {nroZ}: con el script cambiado se esperaba UN evento y hubo"
311 f" {len(evZ)}: {evZ}")
312 if "app.js" not in evZ[0]:
313 fatal(f"carga {nroZ}: el evento no nombra el script: {evZ[0]}")
314 # ── LA INSIGNIA, QUE ES LO ÚNICO QUE EL USUARIO VE DE TODA ESTA CADENA ────────────────
315 # La extensión la RELEE con `getBadgeText` después de ponerla, así que esto no es «se llamó a
316 # la API»: es lo que quedó puesto. Sin medirla, el aviso sería la única pieza sin verificar.
317 if insZ != '"1"':
318 fatal(f"carga {nroZ}: la insignia quedó en {insZ}, se esperaba \"1\" — el evento no"
319 " llegó a la única parte que el usuario ve")
320
321 estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
322 ficheros = sorted(os.listdir(estado)) if os.path.isdir(estado) else []
323 if "registro.postcard" not in ficheros or "bitacora.postcard" not in ficheros:
324 fatal(f"el host no dejó su estado en disco (hay {ficheros}) — sin eso, cada arranque del"
325 " navegador volvería a «aprendiendo» y nada alertaría nunca")
326 print(f"\nestado en disco: {ficheros}")
327
328 print("\n✓ la cadena entera anda: el navegador ejercita `filterResponseData` →"
329 " native messaging → puriy-costura → puriy-sct")
330 print("✓ una carga observada = una visita (el invariante que rompía agrupar por tabId)")
331 print("✓ y la insignia dice lo que tiene que decir: vacía en las cinco, «1» en la sexta")
332 print("✓ aprende tres cargas, se estabiliza en la cuarta, NO alerta en la quinta con el"
333 " mismo código, y alerta en la sexta con el código cambiado")
334 print("✓ y el aprendizaje sobrevive a cerrar el navegador seis veces: el estado está en disco")
335 return 0
336 finally:
337 servidor.shutdown()
338 shutil.rmtree(tmp, ignore_errors=True)
339
340
341sys.exit(main())