diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index cc0affa5..36248ec5 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -811,6 +811,52 @@ el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\ recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces. +### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09) + +El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el +re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas, +y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente: + + VIVO el JS de atuq.cfg corre + VENTANA con gBrowser + PREF splitView.enabled=true + WRAPPER tabs=2 + ACTIVA si + BROWSERS 2 + +**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí +sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa +`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La +sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`. + +Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el +orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al +revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de +arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después +extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra. + +**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En +`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con +`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y +`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se +queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que +contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.** + +Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los +contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin +usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés: +declarar como deuda propia algo que upstream ya nos dio. + +⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con +la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que +lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador +vacío para «tenerlo listo». + +El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que +si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija +y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a +una que funciona. + ### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo Escribir la receta destapó un muro que este documento no había visto: `[source]` era @@ -966,6 +1012,54 @@ la que se probó. Lo que hay que saber para escribir el host: apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala. Fija y configura; instalar, no. +### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09) + +La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y +mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde +`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos +nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy +`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque +los dos apuntan a los mismos ficheros. + +`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre +`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta +misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—, +tres escenarios: + +| escenario | qué se rompe | motor (`homepageOverride`) | perfil | +|---|---|---|---| +| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas | +| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** | +| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** | + +⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis +queda confirmado y el README corregido. + +Dos cosas que este guión trae y que valen más que el veredicto: + +1. **El control es por construcción, no un modo aparte.** La sonda que le pregunta al motor vive en + `distribution/extensions/` y **ninguna política la nombra**: si la sonda habla, el escaneo está + vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda + el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala. +2. **La segunda señal que probé NO discrimina, y queda dicho.** Esperaba que el `location` de + `extensions.json` separara los mecanismos (`app-system-defaults` vs. `app-profile`). No: los dos + XPI salen `app-profile` **también** cuando los instala la carpeta y no hay política que los + mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal + que uno cree que discrimina y no discrimina es peor que ninguna. + +**Por qué esto es un guardián y no un test más.** Mozilla lleva años retirando el *sideloading*, y +el día que la carpeta deje de instalar, atuq arranca **sin ninguna de sus extensiones** y sin una +línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor +simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la +dep, no cuando alguien toca atuq. + +⚠ **Y por eso mismo hay que decir lo que todavía NO está: ninguno de los `test-atuq-*` corre en el +latido.** `cosecha-cron.sh` sólo ejecuta `vigia-sonames.py`; los de atuq se corren a mano. Meterlos +cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga +hidratado el rootfs de `scripts/atuq-nested.sh`, así que **es una decisión con precio y no un +`echo` más en el cron** — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el +sitio es el bloque de guardianes de `cosecha-cron.sh` (L341-352). + ## 8. Plan, por unidades de trabajo Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la @@ -984,6 +1078,8 @@ siguiente. | 4.e | **v0.3: página de inicio y pestaña nueva** por extensión de sistema; exigió `MOZ_REQUIRE_SIGNING` vacío. **Verificado 2026-09-07** con `scripts/test-atuq-inicio.py`: se le pregunta al MOTOR (`browserSettings.homepageOverride` / `newTabPageOverride`) y da `moz-extension://…/inicio.html` en las dos, contra `about:home`/`about:newtab` en el control | y con eso queda destrabada la unidad 6 (`sct`) | rebuild de firefox | | 4.f | **Los códecs, medidos** ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el `LD_LIBRARY_PATH` que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido `scripts/test-atuq-codecs.sh` con control negativo | «se ve el vídeo», que ningún auditor de ELF podía responder | — | | 4.c | `MOZ_BUILD_DATE` determinista ✅ — `BuildID=19700101000001`, con guardián en install y **`why-differs` 56/56 sobre dos builds reales** | el invariante de reproducibilidad | — | +| 4.g | **El chrome se programa desde `atuq.cfg`, sin abrir `omni.ja`** ✅ 2026-09-09 — y de paso: la **vista dividida ya la trae el motor y viene prendida** (fx 154), ejercitada de verdad con `gBrowser.addTabSplitView` ⇒ `activeSplitView` + 2 navegadores. Guardián `scripts/test-atuq-chrome.py` con control negativo del propio motor | retira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el `jarlog` | — | +| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — | | 5 | Host de native messaging (tawasuyu) — **camino COMPROBADO 2026-09-07** (§7.bis): sonda extensión↔proceso nativo con control negativo, y las tres trampas medidas (dónde va el manifiesto, qué instala de verdad, y que el host tiene que ser un proceso largo) | los verbos del §6 | 4 | | 6 | `sct` v1 (extensión + testigo) | el diferenciador que nadie tiene | 5 | | 7 | Descargas al CAS | 6.2, y alimenta 6.9 | 5 | diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index d1d214e3..4e25468c 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -3,29 +3,58 @@ `atuq` (quechua: *zorro*) es el navegador de la distro: **un artefacto DERIVADO de `firefox`**, no un fork de su fuente. El diseño completo está en [`docs/26-atuq-envoltorio-gecko.md`](../../docs/26-atuq-envoltorio-gecko.md). -Este directorio ES la fuente de atuq — `recipes/atuq.toml` lo declara con `source.dir` y hammer lo +Este directorio ES la fuente de atuq — `recipes/atuq.toml` lo declara con `source.dir` y takana lo hashea por CONTENIDO. Editar un fichero de acá mueve el `ArtifactHash`; no hay commit que pinear. +⚠ **Y ESTE README también entra en el hash.** O sea que escribir acá el resultado de una medición +invalida el artefacto sobre el que se midió: el hash que un párrafo cita es siempre el +PREDECESOR del vigente. Es esperable y no es deuda —el rebuild son 6 s mientras el `firefox` +vigente esté sellado—, pero hay que saberlo antes de leer un hash de acá y pensar que el fichero +quedó viejo. Los guardianes de `scripts/test-atuq-*.py` se NIEGAN a medir un artefacto que no sea +el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y cada reselle deja +340 M más en el store: conviene juntar las ediciones en una. + ## Qué hay acá y dónde cae | Acá | En el artefacto | Qué hace | |---|---|---| | `prefs/autoconfig.js` | `defaults/pref/autoconfig.js` | Le dice a Gecko que lea `atuq.cfg`. Es el único gancho posible antes de que arranque el perfil. | | `atuq.cfg` | `/atuq.cfg` | Prefs de fábrica + carga la hoja de estilo del chrome. | -| `chrome/atuq.css` | `/chrome/atuq.css` | El aspecto. Se inyecta como AGENT_SHEET desde `atuq.cfg`. | -| `distribution/policies.json` | `/distribution/policies.json` | Política de fábrica: telemetría, updates, **la instalación de las extensiones**, los CONTENEDORES y la config de fábrica del proxy. | +| `chrome/atuq.css` | `/chrome/atuq.css` | El aspecto. Se registra como **USER_SHEET** desde `atuq.cfg` — el mismo nivel de cascada que `userChrome.css`, para que el usuario pueda pisarlo. (Decía AGENT_SHEET; el fichero nunca lo usó, y AGENT_SHEET además alcanzaría al contenido de las páginas.) | +| `distribution/policies.json` | `/distribution/policies.json` | Política de fábrica: telemetría, updates, el MODO y la CONFIG de las extensiones (no su instalación — ver más abajo), los CONTENEDORES y la config de fábrica del proxy. | | `extensions/inicio/` | `/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. | | `extensions/proxy/` | `/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). | -## Por qué la extensión se instala por POLÍTICA y no por carpeta +## Quién instala las extensiones — y este README decía lo contrario -Dejar el `.xpi` en `distribution/extensions/` era el mecanismo clásico de las distros y **ya no -funciona**: Firefox retiró el *sideloading* desde esa carpeta. Se comprobó midiendo, no leyendo — con -el XPI puesto ahí, el `extensions.json` del perfil no lo mencionaba siquiera, y el log no decía nada: -un fallo perfectamente silencioso. +**Las instala el ESCANEO de `distribution/extensions/`.** `ExtensionSettings` de la política las +FIJA y las CONFIGURA; instalarlas, no. -El mecanismo vigente es `ExtensionSettings` en `policies.json`, con `install_url` apuntando al XPI -por ruta absoluta del FHS. Eso es estable acá porque el árbol se hidrata siempre en `/usr/lib/atuq`. +⚠ Esta sección decía justo lo contrario —que el *sideloading* desde la carpeta «ya no funciona» y +que instalaba la política— y era **falso**. Los dos mecanismos apuntan a los MISMOS ficheros, así +que ninguna observación del artefacto los distingue y la política *parece* ser la que instala. Para +saberlo hay que romper uno por vez, y eso es lo que hace `scripts/test-atuq-instalacion.py` +(tres escenarios, medidos el 2026-09-09 sobre `atuq b3:fab2fbfb` y repetidos sobre `b3:9e16ac35`): + +| escenario | qué se rompe | resultado | +|---|---|---| +| `as-is` | nada | las dos extensiones puestas; la home es la nuestra | +| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **siguen puestas** y la home sigue siendo la nuestra ⇒ instala la carpeta | +| `policy-only` | los XPI mudados fuera de la carpeta, con la política apuntando a la ruta nueva | **no aparecen** y la home vuelve a `about:home` ⇒ `install_url` con `file://` no instala nada | + +En las tres corridas hay un CONTROL por construcción: una extensión sonda que vive en la carpeta y +que **ninguna política nombra**. Si la sonda habla, el escaneo está vivo en esa corrida; una corrida +muda se declara rota en vez de leerse como «no instaló». + +Consecuencias prácticas para quien agregue una extensión: + +1. el XPI **tiene que quedar en `distribution/extensions/`** — es lo único que la instala; +2. la entrada en `ExtensionSettings` no es opcional por eso: es la que la deja `normal_installed` + (el usuario la puede quitar) y la que le entrega su `storage.managed` por `3rdparty.Extensions`; +3. `extensions.autoDisableScopes = 0` en `atuq.cfg` es lo que evita que el escaneo las instale + **desactivadas**; +4. el `location` que `extensions.json` le pone al complemento **no dice de qué mecanismo salió**: + medido, los instalados por la carpeta también salen `app-profile`. ## Una extensión nueva no cuesta nada, y su id tiene un solo dueño @@ -94,12 +123,36 @@ cenefa andina, sobre disco teal y anillo noche. logo con cenefa, no un defecto del escalado: la salida sería un dibujo simplificado para ese tamaño, y eso lo decide quien dibujó el zorro. +## El chrome se programa desde `atuq.cfg`, no abriendo `omni.ja` + +Esto era un pendiente («el chrome de verdad dentro de `omni.ja`») y resultó ser dos cosas falsas. +Medido con `scripts/test-atuq-chrome.py` sobre `atuq b3:9e16ac35`: + +1. **No hace falta entrar al zip para programar el chrome.** `atuq.cfg` ya corre con privilegios —de + ahí sale `nsIStyleSheetService`—, así que desde ahí se observa `browser-delayed-startup-finished` + y se toca el `gBrowser` de CADA ventana. La sonda abre dos pestañas y las parte en dos, desde + `atuq.cfg`, sin tocar `omni.ja`. + + Y es la vía que hay que usar, no un atajo: re-empacar `omni.ja` pelea con el orden del `jarlog` + del PGO —por eso el `jarlog` está aparcado—, así que **cada función nueva del chrome que NO entre + al zip es una que no compite con la optimización de arranque.** + +2. **La vista dividida ya la trae el motor, y prendida.** Firefox 154 lleva + `tabbrowser/tabsplitview.js` (492 líneas), `opentabs-splitview.mjs` y `split-view-footer.js` + dentro de `browser/omni.ja`, y `defaults/preferences/firefox.js` dice + `pref("browser.tabs.splitView.enabled", true)`. La sonda lo ejercita de verdad — + `gBrowser.addTabSplitView([t1, t2])` ⇒ `activeSplitView` puesta y `splitViewBrowsers.length == 2` + — así que no es «existe el fichero»: **funciona en nuestro build**. No hay nada que escribir acá. + +⚠ Lo que esto NO dice: atuq **todavía no lleva JS de chrome propio**. Lo medido es el GANCHO, con la +sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que lo +justifique, el fichero va en `chrome/atuq.js` y lo carga `atuq.cfg` igual que la hoja de estilo. + +El control negativo de la sonda sale del propio motor: `addTabSplitView` documenta que si TODAS las +pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija y exige eso. + ## Lo que TODAVÍA no está -- **El chrome de verdad dentro de `omni.ja`** (split view, dientes propios). El re-empaque - determinista ya está resuelto y probado; lo que falta es el contenido. Sigue en pie la condición - de **preservar el orden del `jarlog`** cuando el PGO exista, o se tira a la basura la - optimización de arranque. - **La extensión de `sct`** y el host de native messaging (SDD 26 §6.1 y §7). *(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco» está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a diff --git a/scripts/test-atuq-chrome.py b/scripts/test-atuq-chrome.py new file mode 100755 index 00000000..7f040989 --- /dev/null +++ b/scripts/test-atuq-chrome.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""¿Corre JS NUESTRO en el chrome de `atuq`, y funciona la vista dividida? (SDD 26 §4) + + python3 scripts/test-atuq-chrome.py # exige las dos cosas + python3 scripts/test-atuq-chrome.py --negative-control # con las pestañas FIJADAS: no debe partir + +── LAS DOS PREGUNTAS, Y POR QUÉ VAN JUNTAS ─────────────────────────────────────────────────── +El README de atuq decía que «el chrome de verdad» —split view, dientes propios— exige entrar a +`omni.ja` y poner contenido nuestro adentro. Las dos mitades de esa frase resultaron falsas, y esta +sonda mide cada una: + +1. **El gancho.** `atuq.cfg` ya corre con privilegios (de ahí sale `nsIStyleSheetService`), así que + desde ahí se puede observar `browser-delayed-startup-finished` y tocar el `gBrowser` de CADA + ventana. O sea: **el chrome se programa sin abrir el zip**, que además es lo que hay que hacer, + porque re-empacar `omni.ja` pelea con el orden del `jarlog` del PGO (por eso `jarlog` está + aparcado). +2. **La vista dividida.** Firefox 154 la trae HECHA y PRENDIDA de fábrica: + `browser/omni.ja` lleva `tabbrowser/tabsplitview.js` (492 líneas, el custom element), + `opentabs-splitview.mjs` y `split-view-footer.js`, y `defaults/preferences/firefox.js` dice + `pref("browser.tabs.splitView.enabled", true)`. Eso no es «lo que falta escribir»: es una + función que ya tenemos y que nadie había ejercitado. + +Lo que la sonda hace en la ventana real: abre dos pestañas, llama a `gBrowser.addTabSplitView`, y +después pregunta al motor por `gBrowser.activeSplitView` y `gBrowser.splitViewBrowsers`. Son hechos +del DOM del chrome, no píxeles — corre headless. + +── EL CONTROL NEGATIVO SALE DEL PROPIO MOTOR ───────────────────────────────────────────────── +`addTabSplitView` tiene un `bail out` documentado: si TODAS las pestañas están fijadas, borra el +wrapper y devuelve `null` (no se pueden agrupar). `--negative-control` fija las dos pestañas y exige +justamente eso. Sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a una que +funciona. + +REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado. +""" +import os +import shutil +import subprocess +import sys +import tempfile + +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")) +APPDIR = "usr/lib/atuq" + +# El bloque se APENDA al `atuq.cfg` del artefacto (no lo reemplaza): así se mide el fichero real de +# atuq con la sonda encima, y no una copia que podría haber divergido. La capa de overlay pisa el +# fichero entero, por eso hay que reescribirlo completo con el original adentro. +SONDA = """ +// ── SONDA DE PRUEBA — NO VIAJA EN EL ARTEFACTO (scripts/test-atuq-chrome.py) ─────────────────── +try { + const S = (typeof Services !== "undefined") + ? Services + : ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs").Services; + const lineas = []; + const di = function (s) { + lineas.push(s); + try { dump("CHROME-TEST " + s + "\\n"); } catch (e) {} + // El fichero es la vía FIABLE: `dump()` desde el proceso padre depende de prefs y de cómo se + // compiló. Se usan las dos y el guión lee la que haya. + try { IOUtils.writeUTF8("/salida/chrome.txt", lineas.join("\\n") + "\\n"); } catch (e) {} + }; + di("VIVO el JS de atuq.cfg corre"); + S.obs.addObserver(function observador(win) { + S.obs.removeObserver(observador, "browser-delayed-startup-finished"); + try { + di("VENTANA " + (win.gBrowser ? "con gBrowser" : "SIN gBrowser")); + di("PREF splitView.enabled=" + + S.prefs.getBoolPref("browser.tabs.splitView.enabled", false)); + const principal = S.scriptSecurityManager.getSystemPrincipal(); + const t1 = win.gBrowser.addTab("about:blank", { triggeringPrincipal: principal }); + const t2 = win.gBrowser.addTab("about:blank", { triggeringPrincipal: principal }); + if (FIJADAS) { win.gBrowser.pinTab(t1); win.gBrowser.pinTab(t2); } + const sv = win.gBrowser.addTabSplitView([t1, t2]); + di("WRAPPER " + (sv ? ("tabs=" + sv.tabs.length) : "null")); + if (sv && sv.tabs.length) { win.gBrowser.selectedTab = sv.tabs[0]; } + win.setTimeout(function () { + di("ACTIVA " + (win.gBrowser.activeSplitView ? "si" : "no")); + di("BROWSERS " + win.gBrowser.splitViewBrowsers.length); + di("FIN"); + }, 2000); + } catch (e) { + di("EXCEPCIÓN " + e); + } + }, "browser-delayed-startup-finished"); +} catch (e) { + try { dump("CHROME-TEST EXCEPCIÓN-EXTERIOR " + e + "\\n"); } catch (e2) {} +} +""" + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(): + """El artefacto VIGENTE de atuq, por `takana hash` y nunca por glob. + + ⚠ ESCOTILLA `ATUQ_DIR`, que grita: el corpus es compartido y otro frente puede re-hashear + `firefox` dejando el atuq vigente sin sellar durante las horas del build. Un resultado obtenido + con la escotilla NO se cita como «el atuq de hoy pasa». + """ + forzado = os.environ.get("ATUQ_DIR") + if forzado: + if not os.path.isdir(forzado): + fatal(f"ATUQ_DIR={forzado} no existe") + print(f"⚠ ATUQ_DIR: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente") + return forzado + r = subprocess.run([HAMMER, "--store", STORE, "hash", "recipes/atuq.toml"], + cwd=ROOT, capture_output=True, text=True) + if r.returncode != 0: + fatal(f"no pude hashear atuq: {r.stderr.strip()}") + h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") + d = os.path.join(STORE, f"{h}-atuq") + if not os.path.isdir(d): + fatal(f"atuq 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: las dos pestañas van FIJADAS; el motor tiene que negarse") + 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() + print(f"artefacto: {os.path.basename(atuq)}") + + tmp = tempfile.mkdtemp(prefix="atuq-chrome-") + salida = os.path.join(tmp, "salida") + capa_app = os.path.join(tmp, "capa", APPDIR) + os.makedirs(os.path.join(salida, "perfil")) + os.makedirs(capa_app) + try: + original = open(os.path.join(atuq, APPDIR, "atuq.cfg"), encoding="utf8").read() + with open(os.path.join(capa_app, "atuq.cfg"), "w", encoding="utf8") as fh: + fh.write(original) + fh.write(SONDA.replace("FIJADAS", "true" if negativo else "false")) + + 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') + + guion = """ + mkdir -p /root + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id + timeout 60 /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", + "--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() + lineas = [l[len("CHROME-TEST "):] for l in log.splitlines() if l.startswith("CHROME-TEST ")] + fichero = os.path.join(salida, "chrome.txt") + if os.path.exists(fichero): + del_fichero = open(fichero, errors="replace").read().splitlines() + # El fichero es la fuente autorizada si dice MÁS que el stdout. + if len(del_fichero) > len(lineas): + lineas = del_fichero + for l in lineas: + print(" " + l) + + def valor(clave): + for l in lineas: + if l.startswith(clave + " "): + return l.split(" ", 1)[1] + return None + + if not lineas: + destino = os.path.join(ROOT, "work", "atuq-chrome.log") + os.makedirs(os.path.dirname(destino), exist_ok=True) + open(destino, "w").write(log) + fatal("la sonda no dijo NADA: el JS de `atuq.cfg` no corrió, o no llegó a la ventana." + f" Log en {destino}") + if valor("VIVO") is None: + fatal("no salió la línea VIVO — el bloque de la sonda no se ejecutó") + if "FIN" not in lineas and valor("EXCEPCIÓN"): + fatal(f"el JS del chrome se rompió: {valor('EXCEPCIÓN')}") + + wrapper, activa, browsers = valor("WRAPPER"), valor("ACTIVA"), valor("BROWSERS") + if negativo: + if wrapper != "null": + fatal(f"con las dos pestañas FIJADAS el motor partió igual (WRAPPER={wrapper})" + " — esta prueba no distingue nada") + if activa != "no": + fatal(f"control negativo: ACTIVA={activa}, se esperaba «no»") + print("✓ control negativo: con las pestañas fijadas NO hay vista dividida" + " (la sonda sí lo vería)") + return 0 + + if valor("VENTANA") != "con gBrowser": + fatal("el observador corrió pero la ventana no traía `gBrowser`") + if valor("PREF") != "splitView.enabled=true": + fatal(f"la vista dividida viene APAGADA en este build ({valor('PREF')})" + " — habría que prenderla en atuq.cfg") + if wrapper != "tabs=2": + fatal(f"`addTabSplitView` no armó la vista de dos pestañas (WRAPPER={wrapper})") + if activa != "si": + fatal(f"la vista se creó pero el motor no la reporta ACTIVA (ACTIVA={activa})") + if browsers != "2": + fatal(f"el motor no reporta dos navegadores en la vista (BROWSERS={browsers})") + print("✓ el JS de `atuq.cfg` llega al `gBrowser` de la ventana:" + " el chrome se programa SIN abrir `omni.ja`") + print("✓ la vista dividida del motor funciona en nuestro build:" + " dos pestañas, vista ACTIVA y dos navegadores") + return 0 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +sys.exit(main()) diff --git a/scripts/test-atuq-instalacion.py b/scripts/test-atuq-instalacion.py new file mode 100755 index 00000000..3026b279 --- /dev/null +++ b/scripts/test-atuq-instalacion.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""¿QUIÉN instala de verdad las extensiones de `atuq`: la política o la carpeta? (SDD 26 §7.bis) + + python3 scripts/test-atuq-instalacion.py # los tres escenarios y el veredicto + python3 scripts/test-atuq-instalacion.py --scenario as-is # uno solo, para diagnosticar + +── POR QUÉ EXISTE ESTA PRUEBA ──────────────────────────────────────────────────────────────── +Dos documentos nuestros se contradecían sobre el mecanismo del que dependen TODAS las extensiones +de atuq —hoy `inicio` y `proxy`, mañana la de `sct`—: + + · `recipes/atuq/README.md` decía que el *sideloading* desde `distribution/extensions/` «ya no + funciona» y que lo que instala es `ExtensionSettings.install_url` de la política; + · el §7.bis del SDD 26 midió lo contrario: que `install_url` con `file://` no instala nada y que + quien instala es el escaneo de la carpeta. + +Los dos mecanismos APUNTAN A LOS MISMOS FICHEROS, así que ninguna observación del artefacto los +distingue: hay que romper uno por vez y mirar qué pasa. Eso es lo que hace este guión. + +── LOS TRES ESCENARIOS ─────────────────────────────────────────────────────────────────────── + as-is el artefacto tal cual ⇒ las extensiones tienen que estar + no-policy `policies.json` SIN `ExtensionSettings` ⇒ si siguen: instala LA CARPETA + policy-only los XPI mudados fuera de la carpeta y la + política apuntando a la ruta nueva ⇒ si no están: la política NO instala + +En los tres se mira lo mismo, y por dos vías independientes: + + el MOTOR una sonda con permiso `browserSettings` pregunta el `homepageOverride`; si el + override de `inicio` agarró, es `moz-extension://…/inicio.html` + el PERFIL `extensions.json` dice si el complemento ESTÁ y si está `active` + +⚠ El `location` de `extensions.json` NO sirve para distinguir el mecanismo, y esto es medido: los +dos XPI de atuq salen `app-profile` **también cuando los instala el escaneo de la carpeta** y no hay +ninguna política que los nombre. Se lo imprime igual porque es barato, pero la única señal que +discrimina es la de arriba: romper un mecanismo por vez y ver si la extensión sigue. + +La sonda es además el CONTROL de que la corrida sirvió: vive en `distribution/extensions/` y NO está +declarada en ninguna política, así que si habla, el escaneo de la carpeta está vivo en esa corrida. +Una corrida muda no se interpreta como «no instaló»: se declara rota. + +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/takana") +STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store")) + +APPDIR = "/usr/lib/atuq" +EXTDIR = f"{APPDIR}/distribution/extensions" +ALTDIR = f"{APPDIR}/xpi-politica" +IDS = ("inicio@atuq.tawasuyu", "proxy@atuq.tawasuyu") + +SONDA = "sonda-instalacion@atuq.tawasuyu" +MANIFIESTO = { + "manifest_version": 2, + "name": "sonda-instalacion", + "version": "1.0", + "browser_specific_settings": {"gecko": {"id": SONDA}}, + "permissions": ["browserSettings"], + "background": {"scripts": ["fondo.js"]}, +} +FONDO = """ +function di(s) { try { dump("INSTALACION-TEST " + s + "\\n"); } catch (e) {} } +(async () => { + try { + const h = await browser.browserSettings.homepageOverride.get({}); + di("HOMEPAGE " + h.value); + } catch (e) { + di("EXCEPCIÓN " + e.message); + } +})(); +""" + +ESCENARIOS = ("as-is", "no-policy", "policy-only") + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(): + """El artefacto VIGENTE de atuq, por `takana hash` y nunca por glob. + + ⚠ ESCOTILLA `ATUQ_DIR` con su aviso puesto: el corpus es compartido y otro frente puede + re-hashear `firefox` dejando el atuq vigente sin sellar durante las horas del build. Con la + escotilla se puede medir contra uno viejo A SABIENDAS, y por eso grita. + """ + forzado = os.environ.get("ATUQ_DIR") + if forzado: + if not os.path.isdir(forzado): + fatal(f"ATUQ_DIR={forzado} no existe") + print(f"⚠ ATUQ_DIR: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente") + return forzado + r = subprocess.run([HAMMER, "--store", STORE, "hash", "recipes/atuq.toml"], + cwd=ROOT, capture_output=True, text=True) + if r.returncode != 0: + fatal(f"no pude hashear atuq: {r.stderr.strip()}") + h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") + d = os.path.join(STORE, f"{h}-atuq") + if not os.path.isdir(d): + fatal(f"atuq b3:{h[:8]} no está sellado — construilo antes") + return d + + +def politica_sin_extensionsettings(base): + p = json.load(open(base)) + p["policies"].pop("ExtensionSettings", None) + return p + + +def politica_apuntando_a(base, directorio): + p = json.load(open(base)) + for ident, cfg in p["policies"]["ExtensionSettings"].items(): + cfg["install_url"] = f"file://{directorio}/{ident}.xpi" + return p + + +def correr(escenario, atuq): + """Una corrida de atuq headless con perfil nuevo. Devuelve (homepage, locations, log).""" + tmp = tempfile.mkdtemp(prefix=f"atuq-inst-{escenario}-") + salida = os.path.join(tmp, "salida") + capa_ext = os.path.join(tmp, "capa", APPDIR.lstrip("/"), "distribution", "extensions") + perfil = os.path.join(salida, "perfil") + os.makedirs(perfil) + os.makedirs(capa_ext) + try: + with zipfile.ZipFile(os.path.join(capa_ext, f"{SONDA}.xpi"), "w") as z: + z.writestr("manifest.json", json.dumps(MANIFIESTO, indent=1)) + z.writestr("fondo.js", FONDO) + + with open(os.path.join(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') + + base = os.path.join(atuq, APPDIR.lstrip("/"), "distribution", "policies.json") + preparar = "true" + if escenario == "no-policy": + nueva = politica_sin_extensionsettings(base) + elif escenario == "policy-only": + nueva = politica_apuntando_a(base, ALTDIR) + preparar = (f"mkdir -p {ALTDIR} && " + + " && ".join(f"mv {EXTDIR}/{i}.xpi {ALTDIR}/{i}.xpi" for i in IDS)) + else: + nueva = None + if nueva is not None: + capa_pol = os.path.join(tmp, "capa", APPDIR.lstrip("/"), "distribution") + with open(os.path.join(capa_pol, "policies.json"), "w") as fh: + json.dump(nueva, fh, indent=2) + + guion = f""" + mkdir -p /root + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id + {preparar} + timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless about:blank \ + > /salida/consola.log 2>&1 + cp /salida/perfil/extensions.json /salida/extensions.json 2>/dev/null || true + 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", + "--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() + homepage = None + for l in log.splitlines(): + if l.startswith("INSTALACION-TEST HOMEPAGE "): + homepage = l.split(" ", 2)[2] + locations = {} + ej = os.path.join(salida, "extensions.json") + if os.path.exists(ej): + try: + for a in json.load(open(ej)).get("addons", []): + if a.get("id") in IDS or a.get("id") == SONDA: + locations[a["id"]] = (a.get("location"), bool(a.get("active"))) + except (ValueError, KeyError) as e: + print(f" ⚠ no pude leer extensions.json: {e}") + return homepage, locations, log + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def main(): + argv = sys.argv[1:] + pedido = None + if "--scenario" in argv: + pedido = argv[argv.index("--scenario") + 1] + if pedido not in ESCENARIOS: + fatal(f"escenario desconocido: {pedido} (hay {', '.join(ESCENARIOS)})") + 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() + print(f"artefacto: {os.path.basename(atuq)}") + res = {} + for e in (ESCENARIOS if pedido is None else (pedido,)): + print(f"\n== escenario {e}") + homepage, locations, log = correr(e, atuq) + nuestra = bool(homepage and "inicio.html" in homepage) + print(f" MOTOR homepage = {homepage}" + f" ⇒ {'la de atuq' if nuestra else 'la de Firefox'}") + if not homepage: + destino = os.path.join(ROOT, "work", f"atuq-instalacion-{e}.log") + os.makedirs(os.path.dirname(destino), exist_ok=True) + open(destino, "w").write(log) + fatal(f"la sonda no dijo NADA en «{e}» — corrida rota, no resultado. Log en {destino}") + for ident, (loc, activo) in sorted(locations.items()): + print(f" PERFIL {ident:34s} location={loc} activo={activo}") + for ident in IDS: + if ident not in locations: + print(f" PERFIL {ident:34s} NO ESTÁ en extensions.json") + res[e] = (nuestra, locations) + + if pedido is not None: + return 0 + + print() + fallos = [] + if not res["as-is"][0]: + fallos.append("tal cual, la página de inicio NO es la de atuq — atuq está roto, " + "no es un problema del mecanismo") + if not res["no-policy"][0]: + fallos.append("sin `ExtensionSettings` las extensiones NO se instalan ⇒ el escaneo de " + "`distribution/extensions/` YA NO alcanza: hay que revisar el §7.bis y el " + "README, porque todas las extensiones de atuq cuelgan de ahí") + if res["policy-only"][0]: + fallos.append("con los XPI FUERA de la carpeta y sólo la política apuntándolos, las " + "extensiones SÍ se instalaron ⇒ `install_url` con `file://` ahora funciona " + "y el §7.bis quedó viejo") + if fallos: + for f in fallos: + print(f"✗ {f}", file=sys.stderr) + return 1 + print("✓ quien instala es el ESCANEO de `distribution/extensions/`: sin la política siguen " + "puestas, y con la política sola no aparecen.") + print(" La política las FIJA y las CONFIGURA; instalarlas, no. (SDD 26 §7.bis)") + return 0 + + +sys.exit(main())