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.
This commit is contained in:
Sergio
2026-09-10 23:17:32 +00:00
parent be368df070
commit 10c499777e
8 changed files with 340 additions and 4 deletions
+30 -1
View File
@@ -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
+27 -1
View File
@@ -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.
+4
View File
@@ -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"
}
}
}
+89
View File
@@ -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);
@@ -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"
}
}
@@ -8,6 +8,7 @@
"descargas@atuq.tawasuyu",
"archivo@atuq.tawasuyu",
"medios@atuq.tawasuyu",
"torrent@atuq.tawasuyu"
"torrent@atuq.tawasuyu",
"foco@atuq.tawasuyu"
]
}
+1 -1
View File
@@ -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" }`.
+162
View File
@@ -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())