Files
takana/scripts/test-atuq-nativo.py
T
SergioandClaude Opus 5 639948f2cd atuq: la página de inicio y la pestaña nueva, verificadas contra el propio motor
La v0.3 las puso por extensión de sistema y quedó como afirmación. Que el XPI
esté en el artefacto no dice nada: el override lo puede rechazar el gestor de
extensiones, lo puede pisar una política, o el navegador puede arrancar con el
chrome viejo cacheado. Ninguna de las tres falla ruidosamente — se ve una pestaña
nueva perfectamente normal, que es de otro.

Se mide preguntándole AL MOTOR, no mirando el disco: una sonda con permiso
`browserSettings` lee `homepageOverride` y `newTabPageOverride`.

    positivo  moz-extension://…/inicio.html  (las dos)
    control   about:home · about:newtab

El control negativo borra el XPI de `inicio` dentro del overlay temporal —el
rootfs real no se toca— y exige el resultado contrario.

Y una escotilla `ATUQ_DIR` en las dos sondas nuevas, con su aviso a gritos: el
corpus es compartido y hoy mismo el perfil PGO v2 de otro frente re-hasheó
`firefox` y con él `atuq`, así que el artefacto VIGENTE no existe en ningún store
hasta que alguien pague un build de horas. Sin escotilla no se puede correr una
sola prueba de atuq en esa ventana; con ella se corre contra un artefacto viejo A
SABIENDAS, y por eso el resultado no se puede citar como «atuq de hoy pasa».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNFzrqFBEt6W7maGJKf2Xs
2026-09-07 21:03:25 +00:00

216 lines
10 KiB
Python

#!/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."""
# ⚠ ESCOTILLA `ATUQ_DIR`, y con su aviso puesto. El corpus es compartido: otro frente puede
# mover una receta de arriba —el 2026-09-07 el perfil PGO v2 re-hasheó `firefox` y con él
# `atuq`— y entonces el artefacto VIGENTE no existe en ningún store hasta que alguien pague un
# build de horas. Sin escotilla, ninguna prueba de atuq se puede correr en esa ventana. Con
# escotilla, se puede correr contra un artefacto viejo A SABIENDAS: por eso grita, y por eso el
# resultado no se puede citar como «atuq de hoy pasa».
forzado = os.environ.get("ATUQ_DIR")
if forzado and nombre == "atuq":
if not os.path.isdir(forzado):
fatal(f"ATUQ_DIR={forzado} no existe")
print(f"⚠ ATUQ_DIR: se prueba {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
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())