tawasuyu

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…

TOML 1373Shell 116Rust 74Markdown 641915 Definiciones

atuq §6.5.bis: la extensión `foco` — muestra el foco del sistema y no tiene con qué apagarlo

La mitad del navegador del §6.5, deliberadamente asimétrica: sólo lee. Pregunta `focus.state` (nuevo
en puriy-costura, commit 38815b5f3) y pinta tres estados — `foco`, nada, y `?` cuando nadie escribió
el estado. Ese tercero es el que importa: si «no sé» se redondeara a «apagado», la insignia afirmaría
que no hay foco sin haberlo mirado.

No hay verbo para apagarlo, y es la propiedad y no un pendiente: si el navegador pudiera levantar el
foco, valdría lo que vale un bloqueador de extensión. En tawasuyu hay un test que lo fija; acá el
guardián mide el EFECTO — tras una sesión entera, el fichero de estado quedó igual.

`scripts/test-atuq-foco.py` corre los tres estados sobre el path de PRODUCCIÓN (`/etc/takana/focus`),
no la escotilla de pruebas: una escotilla mide el código, no el contrato con la imagen. Verde sobre
el artefacto vigente (atuq 5d1afc50, puriy-costura 0de6b4ca), y verificado rompiéndolo — con una
extensión parcheada que pinta «foco» siempre (en una COPIA del artefacto, vía ATUQ_DIR), falla
nombrando la insignia.

10c499777efd3e92225a1f189ebdc9775514ba9e · por Sergio · (hace 1 mes) · padre be368df07 · ver el árbol en este commit

Delta semántico · 8

recipes/atuq/extensions/foco/fondo.js

scripts/test-atuq-foco.py

8 archivos · +340 −4

diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index 87b9b60b..b31e3416 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -491,7 +491,7 @@ La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
 | 6.2 | **Descargas direccionadas por contenido** | nadie | store CAS de takana, `tejido` | bajo |
 | 6.3 | **Archivo personal + RAG local** | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | `khipu`, `rag-motor`, `willay-rag` | medio |
 | 6.4 | **Historial y perfil sobre `qullqa`** | nadie | `qullqa-core`, `qullqa-pozo` | medio |
-| 6.5 | **Foco por `cortafuegos`, no por extensión** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `pacha` | bajo |
+| 6.5 | **Foco por `cortafuegos`, no por extensión** ✅ **cadena y navegador; falta quién lo aplica** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `nftables` (nuevo en el corpus) | bajo |
 | 6.6 | **Medios por fuera del navegador** | extensiones sueltas; de fábrica no | `foreign-ytdlp`, `-platform`, `-dlna` | bajo |
 | 6.7 | **IA local en la barra lateral** | Chrome/Edge son nube; Zen no tiene | `rimay`, `iniy` | bajo |
 | 6.8 | **Proxy por contenedor** ✅ **v0.5** | nadie de fábrica | — (API de Firefox) | bajo |
@@ -761,6 +761,35 @@ navegador:
 Mientras eso no exista, lo que `atuq` puede tener —y tiene, §6.5.bis— es la mitad honesta: **ver** el
 estado del foco y **no poder apagarlo**.
 
+#### 6.5.bis La mitad del navegador: `focus.state`, y la asimetría a propósito (2026-09-10)
+
+La extensión `foco` **sólo lee**. Pregunta `focus.state` al host cada minuto —el estado lo cambia root
+por fuera, no hay evento al que suscribirse— y pinta tres cosas: `foco`, nada, o **`?`**. El tercer
+caso es el que importa: el host contesta `unknown` cuando nadie escribió el estado o cuando el
+contenido no se entiende, y **eso no se redondea a `off`**; una insignia que dijera «sin foco» sin
+haber mirado afirmaría un hecho que no tiene.
+
+**Y no hay verbo para apagarlo.** `focus.stop` cae en «verbo desconocido», igual que `focus.off` y
+`focus.start`. No es una omisión pendiente: es la propiedad entera. Si el navegador pudiera levantar
+el foco, esto valdría exactamente lo que vale un bloqueador de extensión —dos clics— que es el
+problema que el §6.5 venía a resolver. Encenderlo tampoco está: aplicar un reglaset pide root y este
+proceso corre como el usuario. Hay un test en tawasuyu que lo fija por nombre, y se puso rojo a
+propósito agregando un `focus.stop` «por comodidad».
+
+El estado vive en `/etc/takana/focus` (lo escribe root junto con el reglaset). `scripts/test-atuq-foco.py`
+corre **el path de producción, no la escotilla de pruebas** —una escotilla mide el código, no el
+contrato con la imagen— en tres sesiones del navegador, y en cada una comprueba tres cosas: qué dijo
+la extensión, qué pintó, y que el fichero **quedó igual** después de la sesión. Eso último es la
+promesa del §6.5 medida del lado del navegador. Verificado rompiéndolo: con una extensión parcheada
+que pinta «foco» pase lo que pase (dentro de una COPIA del artefacto, vía `ATUQ_DIR`), el guardián
+falla nombrando la insignia.
+
+```
+-- fichero de foco: on          → ESTADO on      · INSIGNIA "foco" · quedó on
+-- fichero de foco: off         → ESTADO off     · INSIGNIA ""     · quedó off
+-- fichero de foco: (no existe) → ESTADO unknown · INSIGNIA "?"    · siguió ausente
+```
+
 ### 6.6 Medios fuera del navegador — HECHO (2026-09-10)
 
 Una navegación de PRIMER NIVEL a un medio (`Content-Type: video/*` o `audio/*`) se cancela y la URL se
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index ba41c609..bca0ee85 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -29,6 +29,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y
 | `extensions/archivo/` | `<appdir>/distribution/extensions/archivo@atuq.tawasuyu.xpi` | **Archivo personal** (SDD 26 §6.3). |
 | `extensions/medios/` | `<appdir>/distribution/extensions/medios@atuq.tawasuyu.xpi` | **Medios fuera del navegador** (SDD 26 §6.6). |
 | `extensions/torrent/` | `<appdir>/distribution/extensions/torrent@atuq.tawasuyu.xpi` | **Torrent con identidad** (SDD 26 §6.9). |
+| `extensions/foco/` | `<appdir>/distribution/extensions/foco@atuq.tawasuyu.xpi` | **Modo foco: lo muestra, no lo apaga** (SDD 26 §6.5). |
 | `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
 | `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
 
@@ -240,6 +241,32 @@ sin pestaña es el único resultado inaceptable, y el control negativo del guard
 ⚠ El binario **no viene del mensaje**: es una constante del host. Lo único que viaja es la URL, sólo
 `http(s)`, y detrás de un `--`.
 
+## `foco` — el navegador MUESTRA el foco, y no tiene con qué apagarlo (§6.5)
+
+El foco no es un bloqueador de sitios: es una regla `nft` de **egress por cgroup** que aplicó root, y
+mientras está puesta el navegador **no sale a la red** — con todo lo local andando (el archivo del
+§6.3 se lee, el CAS del §6.2 está). Medido en una máquina con root: el proceso del cgroup en foco no
+abre la conexión y el del cgroup de al lado sí (`scripts/test-foco-egress.sh`).
+
+Esta extensión es la mitad del navegador, y es **asimétrica a propósito: sólo lee**. Pregunta
+`focus.state` al host cada minuto —el estado lo cambia root por fuera, así que no hay evento al que
+suscribirse— y pinta tres cosas y ninguna más: `foco` si está puesto, nada si no, y **`?` si nadie
+escribió el estado**. Ese tercer caso es el que importa: si «no sé» se redondeara a «apagado», la
+insignia afirmaría que no hay foco sin haberlo mirado.
+
+**No hay verbo para apagarlo, y eso es la propiedad y no un pendiente.** Si desde el navegador se
+pudiera levantar, el modo foco valdría lo que vale un bloqueador de extensión: dos clics. En tawasuyu
+hay un test que lo fija (`focus.stop` → «verbo desconocido»), y acá el guardián mide el efecto:
+después de una sesión entera del navegador, el fichero de estado quedó **igual**.
+
+`scripts/test-atuq-foco.py` corre los tres estados y comprueba, por corrida, qué dijo la extensión,
+qué pintó y que el estado no cambió. Verificado rompiéndolo: con una extensión que pinta «foco» pase
+lo que pase, el guardián falla nombrando la insignia.
+
+Lo que todavía NO existe —y no se inventó de paso— es quién pone a `atuq` en un cgroup y quién aplica
+la política: lo primero pide delegación de un subárbol a la sesión (decisión de arje, SDD 10) y lo
+segundo pide root. Ver el §6.5 del SDD 26.
+
 ## `torrent` — el magnet lo toma la suite (§6.9)
 
 La extensión registra un handler para `magnet:` (`protocol_handlers`, que existe en nuestro build),
@@ -324,7 +351,6 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control
   necesita el daemon de embeddings y un backend LLM en la imagen. `willay-rag` es el molde.
 - **La IA local en la barra lateral (6.7)**, que está bloqueada por algo medido: el corpus no tiene
   ninguna receta de LLM ni de embeddings. Es también la que traería el motor que le falta al archivo.
-- **El foco por `cortafuegos` (6.5)**, el último del §6 que no depende de un modelo ni de un daemon.
 - **Que una descarga se pueda COMPARTIR con `tejido`**: hoy el objeto está en el CAS de descargas y
   tejido sirve el del sistema. Falta que el índice de descargas sea una raíz que el GC respete, y ahí
   las dos raíces pueden volver a ser una.
diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json
index 631e37f5..f261d356 100644
--- a/recipes/atuq/distribution/policies.json
+++ b/recipes/atuq/distribution/policies.json
@@ -55,6 +55,10 @@
       "torrent@atuq.tawasuyu": {
         "installation_mode": "normal_installed",
         "install_url": "file:///usr/lib/atuq/distribution/extensions/torrent@atuq.tawasuyu.xpi"
+      },
+      "foco@atuq.tawasuyu": {
+        "installation_mode": "normal_installed",
+        "install_url": "file:///usr/lib/atuq/distribution/extensions/foco@atuq.tawasuyu.xpi"
       }
     }
   }
diff --git a/recipes/atuq/extensions/foco/fondo.js b/recipes/atuq/extensions/foco/fondo.js
new file mode 100644
index 00000000..825e84cd
--- /dev/null
+++ b/recipes/atuq/extensions/foco/fondo.js
@@ -0,0 +1,89 @@
+"use strict";
+// foco — el navegador MUESTRA el modo foco; no lo decide y no lo puede apagar (SDD 26 §6.5), v1.
+//
+// ── QUÉ ES EL FOCO, Y POR QUÉ NO ES UN BLOQUEADOR DE SITIOS ───────────────────
+// El foco lo sostiene el cortafuegos del sistema: una regla `nft` de egress por
+// cgroup que aplicó root. Cuando está puesto, el navegador NO SALE A LA RED — y
+// todo lo local sigue: el archivo personal (§6.3) se lee, el CAS (§6.2) está ahí.
+// No es una lista de dominios: una lista se esquiva con un espejo, y un cgroup sin
+// egress no.
+//
+// ── POR QUÉ ESTA EXTENSIÓN SÓLO MIRA ──────────────────────────────────────────
+// Si desde acá se pudiera levantar el foco, valdría exactamente lo que vale un
+// bloqueador de extensión: lo apaga en dos clics la misma persona que no quería
+// distraerse. El host no expone ningún verbo para apagarlo —`focus.stop` contesta
+// «verbo desconocido», y hay un test que lo fija—, así que esta extensión no tiene
+// con qué, y eso es la propiedad. Encenderlo tampoco: aplicar un reglaset pide root.
+//
+// ── Y POR QUÉ «NO SÉ» ES UNA RESPUESTA ────────────────────────────────────────
+// Si nadie escribió el estado, el host contesta `unknown` y acá se muestra tal cual.
+// Pintar «sin foco» cuando no se miró sería afirmar un hecho que no se tiene.
+
+const HOST = "puriy_costura";
+// Cada cuánto se vuelve a preguntar. El estado lo cambia root por fuera del
+// navegador, así que no hay evento al que suscribirse: se sondea, y despacio.
+const CADA_MS = 60000;
+
+let puerto = null;
+let siguiente = 1;
+
+function di(s) {
+  try { dump("FOCO " + s + "\n"); } catch (e) {}
+}
+
+// La insignia es TODA la interfaz: tres estados, tres textos, y ninguno inventado.
+function pintar(estado, fichero) {
+  const texto = estado === "on" ? "foco" : estado === "unknown" ? "?" : "";
+  const titulo =
+    estado === "on"
+      ? "Modo foco: el sistema tiene al navegador sin salida a la red"
+      : estado === "off"
+      ? "Modo foco apagado"
+      : "Modo foco: no se sabe — nadie escribió el estado en " + (fichero || "el sistema");
+  try {
+    browser.browserAction.setBadgeText({ text: texto });
+    browser.browserAction.setTitle({ title: titulo });
+  } catch (e) {
+    di("INSIGNIA falló " + e);
+  }
+  di('INSIGNIA texto="' + texto + '"');
+}
+
+function conectar() {
+  try {
+    puerto = browser.runtime.connectNative(HOST);
+  } catch (e) {
+    di("SIN HOST " + e);
+    return;
+  }
+  di("CONECTADO " + HOST);
+  puerto.onMessage.addListener((r) => {
+    if (!r || r.verb !== "focus.state") return;
+    if (!r.ok) {
+      di("ERROR " + (r.error || "sin causa"));
+      return;
+    }
+    di("ESTADO " + r.focus + " fichero=" + (r.focus_file || "?"));
+    pintar(r.focus, r.focus_file);
+  });
+  puerto.onDisconnect.addListener(() => {
+    const e = puerto && puerto.error ? puerto.error.message : "sin causa";
+    di("DESCONECTADO " + e);
+    puerto = null;
+  });
+}
+
+function preguntar() {
+  if (!puerto) conectar();
+  if (!puerto) return;
+  try {
+    puerto.postMessage({ id: siguiente++, verb: "focus.state" });
+  } catch (e) {
+    di("ENVÍO falló " + e);
+    puerto = null;
+  }
+}
+
+di("LISTA v1 muestra el foco del sistema; no lo apaga (no tiene con qué)");
+preguntar();
+setInterval(preguntar, CADA_MS);
diff --git a/recipes/atuq/extensions/foco/manifest.json b/recipes/atuq/extensions/foco/manifest.json
new file mode 100644
index 00000000..be359b72
--- /dev/null
+++ b/recipes/atuq/extensions/foco/manifest.json
@@ -0,0 +1,25 @@
+{
+  "manifest_version": 2,
+  "name": "atuq — modo foco",
+  "description": "Muestra si el sistema tiene al navegador en modo foco. No lo apaga: no puede (SDD 26 §6.5).",
+  "version": "1.0",
+  "browser_specific_settings": {
+    "gecko": {
+      "id": "foco@atuq.tawasuyu",
+      "strict_min_version": "115.0"
+    }
+  },
+  "permissions": [
+    "nativeMessaging"
+  ],
+  "background": {
+    "scripts": ["fondo.js"]
+  },
+  "browser_action": {
+    "default_title": "Modo foco",
+    "default_icon": "atuq128.png"
+  },
+  "icons": {
+    "128": "atuq128.png"
+  }
+}
diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json
index 85df956b..8416e7fc 100644
--- a/recipes/atuq/native-messaging/puriy_costura.json
+++ b/recipes/atuq/native-messaging/puriy_costura.json
@@ -8,6 +8,7 @@
     "descargas@atuq.tawasuyu",
     "archivo@atuq.tawasuyu",
     "medios@atuq.tawasuyu",
-    "torrent@atuq.tawasuyu"
+    "torrent@atuq.tawasuyu",
+    "foco@atuq.tawasuyu"
   ]
 }
diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml
index 24319a6e..70826361 100644
--- a/recipes/puriy-costura.toml
+++ b/recipes/puriy-costura.toml
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
 
 [source]
 repo   = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
-commit = "066b3761f7a7a18415b0a59cb671355953bb48a1"
+commit = "38815b5f3625d7d61dd4005195e3f822d11fc380"
 # ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10.
 # tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip
 # async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`.
diff --git a/scripts/test-atuq-foco.py b/scripts/test-atuq-foco.py
new file mode 100644
index 00000000..10ff07b8
--- /dev/null
+++ b/scripts/test-atuq-foco.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""¿El navegador MUESTRA el modo foco del sistema, y lo deja como estaba? (SDD 26 §6.5)
+
+    python3 scripts/test-atuq-foco.py
+
+── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
+Tres corridas, una por estado del fichero que escribe root (`/etc/takana/focus`): `on`, `off` y
+**ausente**. En cada una se mide lo que la extensión DICE y lo que pinta en su insignia, leído del
+`dump` del navegador — no lo que el host contestó, que es la mitad que ya cubren los tests de
+`puriy-costura`.
+
+── Y LA PROMESA DEL §6.5, MEDIDA DEL LADO DEL NAVEGADOR ──────────────────────────────────────
+Después de cada corrida se vuelve a leer el fichero DENTRO de la jaula: tiene que estar **igual**.
+El foco lo sostiene un reglaset `nft` que aplicó root, y la única forma de que eso valga algo es que
+el navegador no pueda deshacerlo. Que no exista un verbo para apagarlo está fijado en tawasuyu
+(`no_hay_verbo_para_apagar_el_foco_y_eso_es_la_propiedad`); acá se comprueba el efecto: el estado
+sobrevive intacto a una sesión entera del navegador.
+
+`unknown` es el caso que más importa de los tres: si el host redondeara «no sé» a «apagado», la
+insignia diría «sin foco» sin que nadie lo haya mirado.
+
+REQUISITOS: el rootfs mínimo de `atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
+"""
+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"))
+# El path de producción, el mismo que la constante del host. Se mide ÉSTE y no la escotilla de
+# pruebas: una escotilla mide el código, no el contrato con la imagen.
+FOCO = "/etc/takana/focus"
+
+
+def fatal(msg):
+    print(f"✗ {msg}", file=sys.stderr)
+    raise SystemExit(1)
+
+
+def artefacto(receta, nombre, escotilla=None):
+    forzado = os.environ.get(escotilla) if escotilla else None
+    if forzado:
+        if not os.path.isdir(forzado):
+            fatal(f"{escotilla}={forzado} no existe")
+        print(f"⚠ {escotilla}: se mide {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")
+    if not os.listdir(d):
+        fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre")
+    return d
+
+
+def correr(atuq, costura, salida, estado):
+    """Una sesión del navegador con el fichero de foco en `estado` (None = no existe)."""
+    poner = "rm -f " + FOCO if estado is None else (
+        f"mkdir -p {os.path.dirname(FOCO)} && printf '%s\\n' '{estado}' > {FOCO}")
+    guion = f"""
+    mkdir -p /root /salida/hogar
+    [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
+    export HOME=/salida/hogar
+    {poner}
+    timeout 45 /usr/bin/atuq --profile /salida/perfil --no-remote --headless 'about:blank' \
+        > /salida/consola.log 2>&1
+    # El estado, RELEÍDO después de que el navegador terminó: es la prueba de que no lo tocó.
+    if [ -f {FOCO} ]; then cp {FOCO} /salida/focus-final; else echo AUSENTE > /salida/focus-final; fi
+    exit 0
+    """
+    subprocess.run([
+        "bwrap",
+        "--overlay-src", ALPINE, "--overlay-src", RFS,
+        "--overlay-src", atuq, "--overlay-src", costura,
+        "--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", "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("FOCO "):] for l in log.splitlines() if l.startswith("FOCO ")]
+    final = ""
+    try:
+        final = open(os.path.join(salida, "focus-final"), errors="replace").read().strip()
+    except OSError:
+        pass
+    return lineas, final, log
+
+
+def main():
+    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
+    costura = artefacto("puriy-costura", "puriy-costura")
+    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}")
+    if not os.path.isdir(RFS):
+        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
+
+    # estado puesto → (lo que la extensión tiene que decir, la insignia, el fichero tras la sesión)
+    casos = [
+        ("on",  "on",      "foco", "on"),
+        ("off", "off",     "",     "off"),
+        (None,  "unknown", "?",    "AUSENTE"),
+    ]
+    tmp = tempfile.mkdtemp(prefix="atuq-foco-")
+    try:
+        for puesto, espera, insignia, tras in casos:
+            salida = os.path.join(tmp, f"caso-{puesto or 'ausente'}")
+            os.makedirs(os.path.join(salida, "perfil"))
+            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')
+
+            print(f"\n-- fichero de foco: {puesto if puesto else '(no existe)'}")
+            lineas, final, log = correr(atuq, costura, salida, puesto)
+            for l in lineas:
+                print("   " + l)
+            if not lineas:
+                destino = os.path.join(ROOT, "work", f"atuq-foco-{puesto or 'ausente'}.log")
+                os.makedirs(os.path.dirname(destino), exist_ok=True)
+                shutil.copy(os.path.join(salida, "consola.log"), destino)
+                fatal(f"la extensión no dijo nada — log en {destino}")
+
+            estados = [l for l in lineas if l.startswith("ESTADO ")]
+            if not estados:
+                fatal(f"la extensión no informó ningún estado: {lineas}")
+            if not estados[-1].startswith(f"ESTADO {espera} "):
+                fatal(f"con el fichero en {puesto!r} dijo «{estados[-1]}» y tenía que decir «{espera}»")
+            if f'INSIGNIA texto="{insignia}"' not in lineas:
+                fatal(f"la insignia no quedó en «{insignia}»: {[l for l in lineas if 'INSIGNIA' in l]}")
+            # Y la promesa: el navegador no cambió el estado.
+            if final != tras:
+                fatal(f"el fichero del foco quedó en {final!r} y tenía que quedar en {tras!r}"
+                      " — el navegador NO debería poder tocarlo")
+            print(f"   ✓ dijo {espera}, pintó «{insignia}», y dejó el estado igual ({final})")
+
+        print("\n✓ los tres estados llegan al navegador tal como los dejó el sistema")
+        print("✓ «no sé» es una respuesta: sin fichero, la insignia dice «?» y no «sin foco»")
+        print("✓ y tras una sesión entera el estado del foco quedó intacto: el navegador no lo apaga")
+        return 0
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
+sys.exit(main())