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…
⎇ 5ac10fb3bab477cf6b9788aa5e4f0bab03d53ec1
scripts/test-atuq-nativo.py
Historia
Quién tocó qué
crudo
takana etapa 3b: las 49 invocaciones pasan a ./target/release/takana
1#!/usr/bin/env python3
2"""¿Puede una extensión de `atuq` hablar con un proceso NATIVO? (SDD 26 §7)
3
4 python3 scripts/test-atuq-nativo.py # sale 0 si el mensaje va y vuelve
5 python3 scripts/test-atuq-nativo.py --negative-control # sin manifiesto: exige que NO vuelva
6
7Todo el §6 del SDD 26 —`sct`, las descargas al CAS, el archivo con RAG, el torrent— pasa por UN solo
8mecanismo: un proceso Rust que habla *native messaging* con la extensión. Antes de escribir ese
9crate en tawasuyu conviene saber si el camino EXISTE en nuestro build, porque es un build propio,
10rebrandeado y con `MOZ_REQUIRE_SIGNING` vacío: nada garantiza que Gecko encuentre el manifiesto ni
11que pueda lanzar el proceso dentro de la jaula.
12
13Esta prueba no escribe el host de verdad. Pone una **sonda**: una extensión de diez líneas con
14permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`. Si el mensaje vuelve, el
15contrato está y el crate se puede escribir contra algo comprobado.
16
17── LO QUE MIDIÓ, Y QUE NO ES OBVIO (2026-09-07) ───────────────────────────────────────────────
18
191. **El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, NO en el appdir.** Gecko lo
20 busca por `XRESysNativeManifests`, que en Linux sale de un `/usr/lib/mozilla` COMPILADO, y el
21 rebranding a `atuq` no lo mueve: la ruta no depende del nombre de la aplicación. Está en las
22 cadenas de `libxul.so` y se comprueba corriendo esto.
23
242. **Un host que escribe y sale PIERDE el mensaje.** La primera sonda hacía `printf` de la respuesta
25 y terminaba; el puerto llegaba a `onDisconnect` **sin error y sin mensaje**, que es el peor
26 informe posible: parece que el camino no existe. Con un `sleep` detrás del `printf` el mensaje
27 aparece. El host de tawasuyu es un proceso LARGO —vive mientras el navegador lo tenga abierto—,
28 así que en producción no se nota; en una prueba, sí.
29
303. **`ExtensionSettings.install_url` con `file://` NO instala nada, y sin una línea de log.** Se
31 probó con `normal_installed` y con `force_installed`, y con el XPI dentro y fuera del appdir:
32 ninguna instala. Lo que instala las extensiones de atuq es el ESCANEO de
33 `distribution/extensions/`. La política sirve para fijarlas y configurarlas —eso ya lo prueba
34 `test-atuq-politica.py`—, pero leerla como «esto es lo que las instala» es un error fácil,
35 porque los dos mecanismos apuntan a los mismos ficheros y se tapan uno al otro.
36
37REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado.
38"""
39import json
40import os
41import shutil
42import subprocess
43import sys
44import tempfile
45import zipfile
46
47ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
48RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
49ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
50HAMMER = os.path.join(ROOT, "target/release/takana")
51STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
52
53EXT_ID = "nativa@atuq.tawasuyu"
54HOST = "puente_atuq"
55
56MANIFIESTO = {
57 "manifest_version": 2,
58 "name": "sonda-nativa",
59 "version": "1.0",
60 "browser_specific_settings": {"gecko": {"id": EXT_ID}},
61 "permissions": ["nativeMessaging"],
62 "background": {"scripts": ["fondo.js"]},
63}
64
65FONDO = """
66function di(s) { try { dump("NATIVA-TEST " + s + "\\n"); } catch (e) {} }
67di("arranca la sonda");
68try {
69 const p = browser.runtime.connectNative("%s");
70 p.onMessage.addListener(m => di("MENSAJE " + JSON.stringify(m)));
71 // El error del puerto es lo ÚNICO que nombra la causa: sin manifiesto dice «No such native
72 // application», y si el host muere raro lo dice ahí. `sendNativeMessage` sólo devuelve un
73 // «An unexpected error occurred» que no distingue nada.
74 p.onDisconnect.addListener(pp => di("DESCONECTADO " + (pp.error ? pp.error.message : "sin error")));
75 p.postMessage({hola: 1});
76} catch (e) {
77 di("EXCEPCIÓN " + e.message);
78}
79""" % HOST
80
81# `\\013` = 11, el largo de `{"ok":true}`, en 4 bytes little-endian. Y el `sleep`: ver el punto 2 de
82# la cabecera — sin él Gecko cierra el puerto sin entregar lo que el host ya había escrito.
83SONDA_HOST = """#!/bin/sh
84echo "host: arrancó" >> /salida/host.log
85printf '\\013\\000\\000\\000{"ok":true}'
86echo "host: respondió" >> /salida/host.log
87sleep 8
88"""
89
90
91def fatal(msg):
92 print(f"✗ {msg}", file=sys.stderr)
93 raise SystemExit(1)
94
95
96def artefacto(receta, nombre):
97 """El artefacto VIGENTE, por `hammer hash` y nunca por glob: con dos en el store, `ls | head`
98 es una ruleta y se puede estar probando el de ayer."""
99 # ⚠ ESCOTILLA `ATUQ_DIR`, y con su aviso puesto. El corpus es compartido: otro frente puede
100 # mover una receta de arriba —el 2026-09-07 el perfil PGO v2 re-hasheó `firefox` y con él
101 # `atuq`— y entonces el artefacto VIGENTE no existe en ningún store hasta que alguien pague un
102 # build de horas. Sin escotilla, ninguna prueba de atuq se puede correr en esa ventana. Con
103 # escotilla, se puede correr contra un artefacto viejo A SABIENDAS: por eso grita, y por eso el
104 # resultado no se puede citar como «atuq de hoy pasa».
105 forzado = os.environ.get("ATUQ_DIR")
106 if forzado and nombre == "atuq":
107 if not os.path.isdir(forzado):
108 fatal(f"ATUQ_DIR={forzado} no existe")
109 print(f"⚠ ATUQ_DIR: se prueba {os.path.basename(forzado)}, que NO es necesariamente el vigente")
110 return forzado
111
112 r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
113 cwd=ROOT, capture_output=True, text=True)
114 if r.returncode != 0:
115 fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
116 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
117 d = os.path.join(STORE, f"{h}-{nombre}")
118 if not os.path.isdir(d):
119 fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
120 return d
121
122
123def main():
124 negativo = "--negative-control" in sys.argv[1:]
125 if negativo:
126 print("== MODO CONTROL NEGATIVO: sin manifiesto, se exige que el mensaje NO vuelva")
127 if not os.path.isdir(RFS):
128 fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
129 atuq = artefacto("atuq", "atuq")
130 print(f"artefacto: {os.path.basename(atuq)}")
131
132 tmp = tempfile.mkdtemp(prefix="atuq-nativo-")
133 salida = os.path.join(tmp, "salida")
134 # La extensión se instala por el ESCANEO de `distribution/extensions/` (ver punto 3 de la
135 # cabecera), así que la sonda entra como una capa de overlay sobre el appdir del artefacto.
136 extdir = os.path.join(tmp, "capa", "usr", "lib", "atuq", "distribution", "extensions")
137 manifdir = os.path.join(tmp, "capa", "usr", "lib", "mozilla", "native-messaging-hosts")
138 os.makedirs(os.path.join(salida, "perfil"))
139 os.makedirs(extdir)
140 os.makedirs(manifdir)
141 try:
142 with zipfile.ZipFile(os.path.join(extdir, f"{EXT_ID}.xpi"), "w") as z:
143 z.writestr("manifest.json", json.dumps(MANIFIESTO, indent=1))
144 z.writestr("fondo.js", FONDO)
145
146 with open(os.path.join(salida, "host.sh"), "w") as fh:
147 fh.write(SONDA_HOST)
148 os.chmod(os.path.join(salida, "host.sh"), 0o755)
149
150 if not negativo:
151 json.dump({"name": HOST, "description": "sonda de native messaging",
152 "path": "/salida/host.sh", "type": "stdio",
153 "allowed_extensions": [EXT_ID]},
154 open(os.path.join(manifdir, f"{HOST}.json"), "w"), indent=1)
155
156 with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
157 # `dump()` es el canal por el que la sonda habla: va al stdout del proceso, sin red.
158 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
159 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
160
161 guion = """
162 mkdir -p /root
163 [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
164 timeout 50 /usr/bin/atuq --profile /salida/perfil --no-remote --headless about:blank \
165 > /salida/consola.log 2>&1
166 exit 0
167 """
168 subprocess.run([
169 "bwrap",
170 "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq,
171 "--overlay-src", os.path.join(tmp, "capa"), "--tmp-overlay", "/",
172 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
173 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
174 "--bind", salida, "/salida",
175 "--uid", "0", "--gid", "0",
176 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
177 "--setenv", "XDG_RUNTIME_DIR", "/run", "--setenv", "HOME", "/root",
178 "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
179 # Dentro de bwrap no se puede crear otro user namespace (`uid_map: EPERM`) ⇒ el sandbox
180 # de contenido de Gecko no arranca y cada proceso hijo muere en bucle. Acá se mide el
181 # camino nativo, no el sandbox del motor.
182 "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
183 "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
184 "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
185 "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
186 "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
187 "sh", "-c", guion,
188 ], check=False)
189
190 log = open(os.path.join(salida, "consola.log"), errors="replace").read()
191 for linea in [l for l in log.splitlines() if l.startswith("NATIVA-TEST")]:
192 print(" " + linea)
193 arrancó = "NATIVA-TEST arranca la sonda" in log
194 volvió = 'NATIVA-TEST MENSAJE {"ok":true}' in log
195 corrió = os.path.exists(os.path.join(salida, "host.log"))
196
197 if not arrancó:
198 shutil.copy(os.path.join(salida, "consola.log"),
199 os.path.join(ROOT, "work", "atuq-nativo.log"))
200 fatal("la sonda ni siquiera arrancó — ¿no se instaló el XPI? log en work/atuq-nativo.log")
201
202 if negativo:
203 if volvió:
204 fatal("SIN manifiesto el mensaje volvió igual — esta prueba no distingue nada")
205 print("✓ control negativo: sin manifiesto no hay mensaje (la prueba SÍ lo vería)")
206 return 0
207 if not volvió:
208 shutil.copy(os.path.join(salida, "consola.log"),
209 os.path.join(ROOT, "work", "atuq-nativo.log"))
210 fatal(f"el mensaje no volvió (el host {'sí' if corrió else 'NO'} llegó a correr)"
211 " — log en work/atuq-nativo.log")
212 print("✓ una extensión de atuq habló con un proceso nativo y le volvió la respuesta")
213 return 0
214 finally:
215 shutil.rmtree(tmp, ignore_errors=True)
216
217
218sys.exit(main())