From c13be792abd617f328dbf738e6bb481bf6639e94 Mon Sep 17 00:00:00 2001 From: Sergio Date: Mon, 7 Sep 2026 20:27:07 +0000 Subject: [PATCH] =?UTF-8?q?atuq:=20el=20camino=20de=20native=20messaging?= =?UTF-8?q?=20EXISTE=20=E2=80=94=20sonda,=20y=20tres=20cosas=20que=20no=20?= =?UTF-8?q?eran=20obvias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo el §6 del SDD 26 —sct, descargas al CAS, archivo con RAG, torrent— pasa por un solo mecanismo: un proceso Rust hablando native messaging con la extensión. Antes de escribir ese crate en tawasuyu conviene saber si el camino existe en NUESTRO build, que es propio, rebrandeado y con MOZ_REQUIRE_SIGNING vacío. Existe. Una extensión de diez líneas y un host de tres: positivo MENSAJE {"ok":true} control DESCONECTADO No such native application puente_atuq El control no sólo falla: NOMBRA la causa, que es lo que confirma que la ruta del manifiesto es la que se probó. TRES COSAS MEDIDAS QUE NO ERAN OBVIAS: 1. El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, NO en el appdir. Gecko lo busca por `XRESysNativeManifests`, que en Linux sale de un `/usr/lib/mozilla` compilado, y el rebranding a atuq no lo mueve. 2. Un host que escribe y SALE pierde el mensaje. La primera sonda hacía printf y terminaba: el puerto llegaba a onDisconnect «sin error y sin mensaje», o sea el peor informe posible — parece que el camino no existe. Con un sleep detrás del printf, el mensaje aparece. El host de verdad es un proceso largo, así que en producción no se nota; en una prueba, sí. 3. `ExtensionSettings.install_url` con `file://` NO instala nada, y sin una línea de log. Probado con normal_installed y con force_installed, y con el XPI dentro y fuera del appdir: ninguna instala. Lo que instala las extensiones de atuq es el ESCANEO de `distribution/extensions/`. La política sirve para fijarlas y configurarlas; leerla como «esto es lo que las instala» es un error fácil, porque los dos mecanismos apuntan a los mismos ficheros y se tapan uno al otro. `sendNativeMessage` no sirve para diagnosticar: devuelve «An unexpected error occurred» para todo. El error del puerto de `connectNative` es el único que nombra la causa. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NNFzrqFBEt6W7maGJKf2Xs --- scripts/test-atuq-nativo.py | 202 ++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 scripts/test-atuq-nativo.py diff --git a/scripts/test-atuq-nativo.py b/scripts/test-atuq-nativo.py new file mode 100644 index 00000000..ead25aab --- /dev/null +++ b/scripts/test-atuq-nativo.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""¿Puede una extensión de `atuq` hablar con un proceso NATIVO? (SDD 26 §7) + + python3 scripts/test-atuq-nativo.py # sale 0 si el mensaje va y vuelve + python3 scripts/test-atuq-nativo.py --negative-control # sin manifiesto: exige que NO vuelva + +Todo el §6 del SDD 26 —`sct`, las descargas al CAS, el archivo con RAG, el torrent— pasa por UN solo +mecanismo: un proceso Rust que habla *native messaging* con la extensión. Antes de escribir ese +crate en tawasuyu conviene saber si el camino EXISTE en nuestro build, porque es un build propio, +rebrandeado y con `MOZ_REQUIRE_SIGNING` vacío: nada garantiza que Gecko encuentre el manifiesto ni +que pueda lanzar el proceso dentro de la jaula. + +Esta prueba no escribe el host de verdad. Pone una **sonda**: una extensión de diez líneas con +permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`. Si el mensaje vuelve, el +contrato está y el crate se puede escribir contra algo comprobado. + +── LO QUE MIDIÓ, Y QUE NO ES OBVIO (2026-09-07) ─────────────────────────────────────────────── + +1. **El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, NO en el appdir.** Gecko lo + busca por `XRESysNativeManifests`, que en Linux sale de un `/usr/lib/mozilla` COMPILADO, y el + rebranding a `atuq` no lo mueve: la ruta no depende del nombre de la aplicación. Está en las + cadenas de `libxul.so` y se comprueba corriendo esto. + +2. **Un host que escribe y sale PIERDE el mensaje.** La primera sonda hacía `printf` de la respuesta + y terminaba; el puerto llegaba a `onDisconnect` **sin error y sin mensaje**, que es el peor + informe posible: parece que el camino no existe. Con un `sleep` detrás del `printf` el mensaje + aparece. El host de tawasuyu es un proceso LARGO —vive mientras el navegador lo tenga abierto—, + así que en producción no se nota; en una prueba, sí. + +3. **`ExtensionSettings.install_url` con `file://` NO instala nada, y sin una línea de log.** Se + probó con `normal_installed` y con `force_installed`, y con el XPI dentro y fuera del appdir: + ninguna instala. Lo que instala las extensiones de atuq es el ESCANEO de + `distribution/extensions/`. La política sirve para fijarlas y configurarlas —eso ya lo prueba + `test-atuq-politica.py`—, pero leerla como «esto es lo que las instala» es un error fácil, + porque los dos mecanismos apuntan a los mismos ficheros y se tapan uno al otro. + +REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado. +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile + +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/hammer") +STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store")) + +EXT_ID = "nativa@atuq.tawasuyu" +HOST = "puente_atuq" + +MANIFIESTO = { + "manifest_version": 2, + "name": "sonda-nativa", + "version": "1.0", + "browser_specific_settings": {"gecko": {"id": EXT_ID}}, + "permissions": ["nativeMessaging"], + "background": {"scripts": ["fondo.js"]}, +} + +FONDO = """ +function di(s) { try { dump("NATIVA-TEST " + s + "\\n"); } catch (e) {} } +di("arranca la sonda"); +try { + const p = browser.runtime.connectNative("%s"); + p.onMessage.addListener(m => di("MENSAJE " + JSON.stringify(m))); + // El error del puerto es lo ÚNICO que nombra la causa: sin manifiesto dice «No such native + // application», y si el host muere raro lo dice ahí. `sendNativeMessage` sólo devuelve un + // «An unexpected error occurred» que no distingue nada. + p.onDisconnect.addListener(pp => di("DESCONECTADO " + (pp.error ? pp.error.message : "sin error"))); + p.postMessage({hola: 1}); +} catch (e) { + di("EXCEPCIÓN " + e.message); +} +""" % HOST + +# `\\013` = 11, el largo de `{"ok":true}`, en 4 bytes little-endian. Y el `sleep`: ver el punto 2 de +# la cabecera — sin él Gecko cierra el puerto sin entregar lo que el host ya había escrito. +SONDA_HOST = """#!/bin/sh +echo "host: arrancó" >> /salida/host.log +printf '\\013\\000\\000\\000{"ok":true}' +echo "host: respondió" >> /salida/host.log +sleep 8 +""" + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(receta, nombre): + """El artefacto VIGENTE, por `hammer hash` y nunca por glob: con dos en el store, `ls | head` + es una ruleta y se puede estar probando el de ayer.""" + 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 + + +def main(): + negativo = "--negative-control" in sys.argv[1:] + if negativo: + print("== MODO CONTROL NEGATIVO: sin manifiesto, se exige que el mensaje NO vuelva") + 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") + print(f"artefacto: {os.path.basename(atuq)}") + + tmp = tempfile.mkdtemp(prefix="atuq-nativo-") + salida = os.path.join(tmp, "salida") + # La extensión se instala por el ESCANEO de `distribution/extensions/` (ver punto 3 de la + # cabecera), así que la sonda entra como una capa de overlay sobre el appdir del artefacto. + extdir = os.path.join(tmp, "capa", "usr", "lib", "atuq", "distribution", "extensions") + manifdir = os.path.join(tmp, "capa", "usr", "lib", "mozilla", "native-messaging-hosts") + os.makedirs(os.path.join(salida, "perfil")) + os.makedirs(extdir) + os.makedirs(manifdir) + try: + with zipfile.ZipFile(os.path.join(extdir, f"{EXT_ID}.xpi"), "w") as z: + z.writestr("manifest.json", json.dumps(MANIFIESTO, indent=1)) + z.writestr("fondo.js", FONDO) + + with open(os.path.join(salida, "host.sh"), "w") as fh: + fh.write(SONDA_HOST) + os.chmod(os.path.join(salida, "host.sh"), 0o755) + + if not negativo: + json.dump({"name": HOST, "description": "sonda de native messaging", + "path": "/salida/host.sh", "type": "stdio", + "allowed_extensions": [EXT_ID]}, + open(os.path.join(manifdir, f"{HOST}.json"), "w"), indent=1) + + with open(os.path.join(salida, "perfil", "user.js"), "w") as fh: + # `dump()` es el canal por el que la sonda habla: va al stdout del proceso, sin red. + fh.write('user_pref("browser.dom.window.dump.enabled", true);\n') + fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n') + + guion = """ + mkdir -p /root + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id + timeout 50 /usr/bin/atuq --profile /salida/perfil --no-remote --headless about:blank \ + > /salida/consola.log 2>&1 + exit 0 + """ + subprocess.run([ + "bwrap", + "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, + "--overlay-src", os.path.join(tmp, "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", "HOME", "/root", + "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1", + # Dentro de bwrap no se puede crear otro user namespace (`uid_map: EPERM`) ⇒ el sandbox + # de contenido de Gecko no arranca y cada proceso hijo muere en bucle. Acá se mide el + # camino nativo, no el sandbox del motor. + "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1", + "sh", "-c", guion, + ], check=False) + + log = open(os.path.join(salida, "consola.log"), errors="replace").read() + for linea in [l for l in log.splitlines() if l.startswith("NATIVA-TEST")]: + print(" " + linea) + arrancó = "NATIVA-TEST arranca la sonda" in log + volvió = 'NATIVA-TEST MENSAJE {"ok":true}' in log + corrió = os.path.exists(os.path.join(salida, "host.log")) + + if not arrancó: + shutil.copy(os.path.join(salida, "consola.log"), + os.path.join(ROOT, "work", "atuq-nativo.log")) + fatal("la sonda ni siquiera arrancó — ¿no se instaló el XPI? log en work/atuq-nativo.log") + + if negativo: + if volvió: + fatal("SIN manifiesto el mensaje volvió igual — esta prueba no distingue nada") + print("✓ control negativo: sin manifiesto no hay mensaje (la prueba SÍ lo vería)") + return 0 + if not volvió: + shutil.copy(os.path.join(salida, "consola.log"), + os.path.join(ROOT, "work", "atuq-nativo.log")) + fatal(f"el mensaje no volvió (el host {'sí' if corrió else 'NO'} llegó a correr)" + " — log en work/atuq-nativo.log") + print("✓ una extensión de atuq habló con un proceso nativo y le volvió la respuesta") + return 0 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +sys.exit(main())