gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga

Resuelve §NO-resuelve 2 del ADR 0015, que era el ticket que más desbloqueaba.
Tres síntomas que parecían distintos —apt sin poder bajar a `_apt`, pacman sin
poder chownear a `alpm`, pressure-vessel sin poder escribir su uid_map— eran la
misma causa: bwrap crea el userns con UN SOLO id.

La cura resultó tener TRES partes, y ninguna sobra:

1. `setcap cap_setuid+ep newuidmap` (+ cap_setgid en newgidmap). shadow.toml los
   instala pero no los provisiona; sin la capability no escriben el mapa.
2. Crear el userns nosotros, mapear el rango de /etc/subuid con newuidmap y
   pasárselo a bwrap con `--userns FD`. bwrap crea el suyo con un solo id A
   PROPÓSITO y nunca llama a newuidmap: el trabajo es de quien lo invoca. El fd
   lo abre la shell (`exec 3<…`), porque un fd sólo cruza el exec si no es
   CLOEXEC y no valía la pena una dep de C para un fcntl.
3. Devolver las capabilities DENTRO del namespace. Ésta no estaba en el plan y
   es la que costó: bwrap las tira todas, y en Linux ser root es tener
   CAP_SETUID, no tener uid 0. Sin ella apt seguía sin poder seteuid(42) — un
   síntoma que parecía de subuid y no lo era. Son seguras por construcción:
   dentro de un userns sólo alcanzan lo que ese namespace posee, o sea nuestros
   propios subuid. CAP_SYS_ADMIN queda fuera y sigue colgando de `nesting`.

MEDIDO después: uid_map de 65537 ids, setgroups: allow, apt instala SIN el
APT::Sandbox::User=root, pacman sincroniza con DownloadUser=alpm INTACTO, y el
userns anidado monta con root=true ⇒ el conflicto 2 de D9 se disuelve solo.

Dos cosas más que salieron por medir, no por pensar:

- El guardián MENTÍA. qorpa-preflight envolvía al hijo en `timeout`, que forkea,
  así que newuidmap apuntaba al PID equivocado y el kernel respondía "Operation
  not permitted" — un falso negativo idéntico a un fallo real. Decía que subuid
  no andaba cuando a mano andaba. Ahora sale exit 0.
- Quitar el impuesto MUEVE el problema: el upper pasa a contener ficheros de los
  subuid (apt deja los suyos con uid 165577) que nuestro uid no puede borrar ⇒
  recreate entra a un userns mapeado para limpiar. Y cuando no hay rango, se
  degrada diciendo la causa exacta en vez de quedar en misterio.

Y un detalle que no es cosmético: `--perms 1777` antes del `--tmpfs /tmp`, o el
_apt al que apt baja no puede escribir su fichero temporal. Un /tmp que no es
1777 no es /tmp.

2 tests nuevos (el rango se lee por usuario; CAP_SYS_ADMIN NO está en las caps
de root, o `nesting` dejaría de ser una decisión). 45/45.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2

89da9c822fa58ce0bf48314d47a831a42067e239 · por Sergio · (hace 1 mes) · padre f19b8b3e4 · ver el árbol en este commit

Delta semántico · 12

crates/hammer-cli/src/qorpa.rs

11 archivos · +554 −310

diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs
index 883b49b7..b4702ace 100644
--- a/crates/hammer-cli/src/qorpa.rs
+++ b/crates/hammer-cli/src/qorpa.rs
@@ -106,6 +106,10 @@ pub enum QorpaCmd {
         /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
         #[arg(long)]
         no_jail: bool,
+        /// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`.
+        /// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
+        #[arg(long)]
+        single_id: bool,
         /// Comando dentro (default: el shell de la imagen). Va después de `--`.
         #[arg(last = true)]
         cmd: Vec<String>,
@@ -126,8 +130,8 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
             create(&resolve_root(root), &id, &base, distro.as_deref())
         }
         QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
-        QorpaCmd::Run { id, root, dry_run, no_jail, cmd } => {
-            run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail)
+        QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
+            run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
         }
         QorpaCmd::Export { .. } => bail!(
             "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
@@ -439,7 +443,27 @@ fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
             }
         }
     }
-    std::fs::remove_dir_all(p)
+    match std::fs::remove_dir_all(p) {
+        Ok(()) => Ok(()),
+        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
+            // SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia
+            // contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y
+            // nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los
+            // creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap,
+            // así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a
+            // salir tal cual, que es lo correcto.
+            let st = Command::new("unshare")
+                .args(["-U", "--map-auto", "-r", "rm", "-rf"])
+                .arg(p)
+                .status()?;
+            if st.success() && !p.exists() {
+                Ok(())
+            } else {
+                Err(e)
+            }
+        }
+        Err(e) => Err(e),
+    }
 }
 
 enum TreeState { Missing, Empty, Populated }
@@ -558,6 +582,24 @@ fn harkaq_exec() -> PathBuf {
         .join("harkaq-exec")
 }
 
+/// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD.
+///
+/// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener
+/// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear —
+/// que es exactamente el síntoma que parecía de subuid y no lo era.
+///
+/// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que
+/// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el
+/// host. Por eso se conceden con `root = true` sin pedir permiso aparte.
+///
+/// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión
+/// `nesting` declarada. Abrir sólo lo declarado, también acá.
+const CAPS_ROOT: &[&str] = &[
+    "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD",
+    "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT",
+    "CAP_KILL", "CAP_AUDIT_WRITE",
+];
+
 /// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
 const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
 /// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
@@ -791,7 +833,126 @@ fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
     (a, warn)
 }
 
-fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool) -> Result<()> {
+// ── el mapeo por rango (subuid) ──────────────────────────────────────────────────────────────────
+//
+// EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado
+// (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no
+// puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su
+// propio mapa. Tres síntomas distintos, una sola causa.
+//
+// LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y
+// pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con
+// capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca.
+//
+// El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd
+// `/proc/<pid>/ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera,
+// hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`.
+
+struct Rango { inicio: u32, cantidad: u32 }
+
+fn leer_subid(fichero: &str, usuario: &str) -> Option<Rango> {
+    let txt = std::fs::read_to_string(fichero).ok()?;
+    txt.lines().find_map(|l| {
+        let mut c = l.split(':');
+        let (u, i, n) = (c.next()?, c.next()?, c.next()?);
+        if u != usuario {
+            return None;
+        }
+        Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? })
+    })
+}
+
+/// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece.
+struct UsernsMapeado {
+    tenedor: std::process::Child,
+    ns_path: String,
+    ids: u32,
+}
+
+impl Drop for UsernsMapeado {
+    fn drop(&mut self) {
+        // El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos
+        // un proceso colgado por cada instancia arrancada.
+        let _ = self.tenedor.kill();
+        let _ = self.tenedor.wait();
+    }
+}
+
+/// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de
+/// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude".
+fn userns_con_rango() -> Result<UsernsMapeado> {
+    let uid = std::fs::metadata("/proc/self")
+        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
+        .unwrap_or(1000);
+    let gid = std::fs::metadata("/proc/self")
+        .map(|m| std::os::unix::fs::MetadataExt::gid(&m))
+        .unwrap_or(1000);
+    let usuario = std::env::var("USER")
+        .or_else(|_| std::env::var("LOGNAME"))
+        .unwrap_or_default();
+    let ru = leer_subid("/etc/subuid", &usuario)
+        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?;
+    let rg = leer_subid("/etc/subgid", &usuario)
+        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?;
+
+    let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id()));
+    std::fs::create_dir_all(&tmp)?;
+    let listo = tmp.join("listo");
+    let adelante = tmp.join("adelante");
+    for f in [&listo, &adelante] {
+        let _ = std::fs::remove_file(f);
+        let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?;
+        if !st.success() {
+            bail!("no pude crear el fifo {}", f.display());
+        }
+    }
+
+    let tenedor = Command::new("unshare")
+        .arg("-U")
+        .arg("sh")
+        .arg("-c")
+        .arg(format!(
+            "echo r > {l}; read _ < {a}",
+            l = listo.display(), a = adelante.display()
+        ))
+        .spawn()
+        .context("no pude ejecutar `unshare -U` — ¿está util-linux?")?;
+    let pid = tenedor.id();
+    let mut guard = UsernsMapeado {
+        tenedor,
+        ns_path: format!("/proc/{pid}/ns/user"),
+        ids: 1 + ru.cantidad,
+    };
+
+    // Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos
+    // el namespace del padre, que es el del host.
+    std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?;
+
+    let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> {
+        let out = Command::new(bin)
+            .args([pid.to_string(), "0".into(), id.to_string(), "1".into(),
+                   "1".into(), r.inicio.to_string(), r.cantidad.to_string()])
+            .output()
+            .with_context(|| format!("no pude ejecutar {bin}"))?;
+        if !out.status.success() {
+            bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim());
+        }
+        Ok(())
+    };
+    mapear("newuidmap", uid, &ru)?;
+    mapear("newgidmap", gid, &rg)?;
+
+    // Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo
+    // matemos. Lo que importa es que el proceso VIVA, no lo que haga.
+    let _ = std::fs::remove_file(&listo);
+    let _ = std::fs::remove_file(&adelante);
+    guard.ids = 1 + ru.cantidad;
+    Ok(guard)
+}
+
+fn run_instance(
+    root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool, single_id: bool,
+) -> Result<()> {
     let (dir, inst) = read_instance(root, id)?;
     let (sha, tree) = resolve_image(root, &inst.base)?;
     let (grant_args, warns) = grants_to_args(&inst.grants);
@@ -799,20 +960,45 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
     // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
     // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
     // que no se declara, no entra (D2, "por defecto NADA").
+    // El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de
+    // `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root
+    // adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con
+    // `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio
+    // haría que "no anda adentro" fuese un misterio en vez de una línea.
+    let mut mapeado: Option<UsernsMapeado> = None;
+    if !single_id && inst.grants.root {
+        match userns_con_rango() {
+            Ok(m) => mapeado = Some(m),
+            Err(e) => eprintln!(
+                "⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \
+                 no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \
+                 escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh"
+            ),
+        }
+    }
+
     let mut args: Vec<String> = vec![
-        "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
+        "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
         "--hostname", "qorpa",
         "--clearenv",
         "--die-with-parent",
     ].into_iter().map(String::from).collect();
+    if mapeado.is_none() {
+        // bwrap crea el suyo, con un solo id.
+        args.insert(0, "--unshare-user".into());
+        if inst.grants.root {
+            args.extend(["--uid", "0", "--gid", "0"].map(String::from));
+        }
+    }
     if inst.grants.root {
-        args.extend(["--uid", "0", "--gid", "0"].map(String::from));
+        for c in CAPS_ROOT {
+            args.push("--cap-add".into());
+            args.push((*c).to_string());
+        }
         if inst.grants.nesting {
-            eprintln!(
-                "⚠ `nesting` y `root` juntos NO funcionan hoy: con el uid remapeado a 0, un userns \
-                 anidado no puede escribir su uid_map ⇒ pressure-vessel no arranca. Poné \
-                 `root = false` para juegos, o esperá al mapeo por rango (subuid + --userns FD)."
-            );
+            // La que permite montar. Sólo con la concesión declarada.
+            args.push("--cap-add".into());
+            args.push("CAP_SYS_ADMIN".into());
         }
     }
 
@@ -823,7 +1009,11 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
     args.push("/".into());
 
     // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
-    args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
+    // `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el
+    // `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal.
+    // Un /tmp que no es 1777 no es /tmp.
+    args.extend(["--proc", "/proc", "--dev", "/dev",
+                 "--perms", "1777", "--tmpfs", "/tmp",
                  "--dir", "/run/user/0", "--chdir", "/"].map(String::from));
     args.extend([
         "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@@ -884,6 +1074,10 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
         eprintln!("⚠ {w}");
     }
     if dry_run {
+        if let Some(m) = &mapeado {
+            println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids);
+            println!("#   exec 3<{}   ⇒   bwrap --userns 3 …", m.ns_path);
+        }
         // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
         let mut linea = String::from("bwrap");
         for a in &args {
@@ -899,9 +1093,25 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
         inst.grants.devices.len(), inst.grants.dirs.len(),
         if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" },
         if inst.grants.seal_image { "sellada" } else { "escribible" });
-
-    let st = Command::new("bwrap").args(&args).status()
-        .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
+    eprintln!("  ids mapeados: {}",
+        match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() });
+
+    // Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el
+    // `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell:
+    // sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar
+    // exactamente lo que pasa.
+    let st = match &mapeado {
+        Some(m) => Command::new("sh")
+            .arg("-c")
+            .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#)
+            .arg("qorpa")
+            .arg(&m.ns_path)
+            .args(&args)
+            .status()
+            .context("no pude ejecutar sh/bwrap")?,
+        None => Command::new("bwrap").args(&args).status()
+            .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?,
+    };
     match st.code() {
         Some(0) => Ok(()),
         Some(c) => std::process::exit(c),
@@ -1225,6 +1435,28 @@ mod tests {
         assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
     }
 
+    #[test]
+    fn el_rango_de_subuid_se_lee_por_usuario() {
+        let d = tempfile::tempdir().unwrap();
+        let f = d.path().join("subuid");
+        std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap();
+        let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap();
+        assert_eq!((r.inicio, r.cantidad), (165536, 65536));
+        assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none());
+    }
+
+    #[test]
+    fn las_caps_de_root_no_incluyen_la_de_montar() {
+        // CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting`
+        // declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el
+        // comportamiento por defecto — que es justo lo que D7 prohíbe.
+        assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}");
+        // Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad.
+        for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] {
+            assert!(CAPS_ROOT.contains(&c), "falta {c}");
+        }
+    }
+
     #[test]
     fn sha_mal_formado_falla_antes_de_tocar_la_red() {
         assert!(normalize_sha256("abc").is_err());
diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md
index cb4aba57..2a507bf3 100644
--- a/docs/adr/0015-imagenes-ajenas.md
+++ b/docs/adr/0015-imagenes-ajenas.md
@@ -310,12 +310,12 @@ arranca bajo Landlock** (D6). Por eso `nesting = true` cuesta la política de fi
 pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no
 puso.
 
-**Conflicto 2 — `root` adentro y anidar se pelean.** Medido: con el uid remapeado a 0 (`--uid 0`), un
-userns anidado **no puede escribir su `uid_map`**. Sin remapear, anida bien pero el gestor de
-paquetes se queja. ⇒ la instancia de juegos y la de gestor de paquetes quieren mapeos **opuestos**,
-y el manifiesto lo declara (`root`, encendido por defecto). Las dos mitades se curan con lo mismo que
-el impuesto de §NO-resuelve 2: **un rango real de subuid** mapeado con `newuidmap` y pasado a bwrap
-con `--userns FD`. Ése es hoy el ticket que más desbloquea del ADR entero.
+**Conflicto 2 — ~~`root` adentro y anidar se pelean~~, RESUELTO el mismo día.** Con el userns de un
+solo id que crea bwrap, `--uid 0` impedía que un userns anidado escribiera su `uid_map`: la
+instancia de juegos y la de gestor de paquetes querían mapeos **opuestos**. Con el mapeo por rango
+de §NO-resuelve 2 el conflicto desaparece —anida **con `root = true`**, verificado— y `root` deja de
+ser una elección incómoda. El campo se queda en el manifiesto porque sigue describiendo quién sos
+adentro, no porque haya que elegir entre dos males.
 
 **Y un detalle que ordena las expectativas:** bwrap **tira todas las capabilities**, así que dentro
 de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios,
@@ -334,23 +334,30 @@ Se escriben acá para que no se descubran en producción.
    capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario.
    Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR.
 
-2. **UID mapping — MEDIDO 2026-09-03, y el veredicto es mejor de lo que decía este párrafo.**
-   Sin userns privilegiados, `root` adentro es tu UID afuera. La instancia ve `uid_map: 0 1001 1` y
-   `setgroups: deny`, y eso rompe todo lo que quiera *otro* id. Medido en las dos imágenes curadas:
-
-   | gestor | qué pasa | severidad |
-   |---|---|---|
-   | `apt` (Ubuntu 24.04) | el método http hace `setgroups` para bajar a `_apt` ⇒ `Operation not permitted` | **para**, pero se saltea con `-o APT::Sandbox::User=root`, y entonces baja 34 MB, instala y corre los triggers de dpkg enteros. Único residuo: `W: chown to root:adm … failed`, un **aviso** |
-   | `pacman` (Arch) | chownea el directorio de descarga al usuario `alpm` ⇒ `Invalid argument` | **para en duro**; con `DownloadUser` comentado sincroniza, y tras `pacman-key --init && --populate` instala y el binario corre |
-
-   ⇒ **subuid no es un muro, es un impuesto.** Un solo id alcanza para instalar paquetes reales en
-   los dos gestores; lo que rompe es el `chown`/`setgroups` a *otro* id, y cada gestor lo hace en un
-   sitio distinto. Lo que sigue faltando para quitar el impuesto: `newuidmap`/`newgidmap` con
-   `cap_setuid`/`cap_setgid` (`recipes/shadow.toml` los INSTALA pero no los provisiona — el mismo
-   hueco espera en la distro propia) **y** una vía para que bwrap use ese mapa: bwrap crea el userns
-   con un solo id a propósito y no llama a `newuidmap`, así que hay que crear el namespace aparte,
-   mapear el rango y pasárselo con `--userns FD`. `scripts/qorpa/qorpa-preflight.sh` ya mide las dos
-   mitades.
+2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda
+   por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt`
+   (no podía bajar a `_apt`), `pacman` (no podía chownear su descarga a `alpm`) y el userns anidado
+   de pressure-vessel. **Tres síntomas, una causa.**
+
+   La cura entera —`hammer qorpa run` la aplica sola cuando puede— tiene **tres partes, y ninguna
+   sobra**:
+
+   | pieza | por qué |
+   |---|---|
+   | `setcap cap_setuid+ep newuidmap` (y `cap_setgid` en `newgidmap`) | el binario está en el corpus (`recipes/shadow.toml`) pero **sin provisionar**; sin la capability no puede escribir el mapa |
+   | crear el userns **nosotros**, mapear el rango de `/etc/subuid` y pasárselo a bwrap con `--userns FD` | bwrap crea el suyo con **un solo id a propósito** y nunca llama a `newuidmap`: el trabajo es de quien lo invoca |
+   | devolver las **capabilities** dentro del namespace (`--cap-add`) | **bwrap las tira todas**, y en Linux ser root es tener `CAP_SETUID`, no tener uid 0. Sin esto `apt` sigue sin poder `seteuid(42)` — un síntoma que *parecía* de subuid y no lo era |
+
+   Con las tres: `uid_map` de **65537 ids**, `setgroups: allow`, `apt` instala **sin** el
+   `APT::Sandbox::User=root`, `pacman` sincroniza con `DownloadUser = alpm` **intacto**, y el
+   userns anidado monta **con `root = true`** ⇒ el conflicto 2 de D9 se disuelve. Las capabilities
+   concedidas son seguras por construcción: dentro de un user namespace sólo alcanzan a lo que ese
+   namespace posee, o sea nuestros propios subuid. `CAP_SYS_ADMIN` queda fuera y cuelga de `nesting`.
+
+   **Y una consecuencia que sólo aparece después:** el `upper/` pasa a contener ficheros de los
+   subuid (apt deja los suyos con uid 165577) que **nuestro uid normal no puede borrar** ⇒
+   `recreate` y la poda tienen que entrar a un userns mapeado para limpiar
+   (`unshare -U --map-auto -r rm -rf`). Quitar el impuesto mueve el problema, no lo evapora.
 
 3. **Segunda cadena de suministro, sin ninguna garantía de hammer.** Sin repro, sin cierre firmado,
    sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3
diff --git a/recipes/incoming-cosmic/dbus-shared.toml b/recipes/dbus-shared.toml
similarity index 100%
rename from recipes/incoming-cosmic/dbus-shared.toml
rename to recipes/dbus-shared.toml
diff --git a/recipes/incoming-cosmic/libsndfile.toml b/recipes/libsndfile.toml
similarity index 100%
rename from recipes/incoming-cosmic/libsndfile.toml
rename to recipes/libsndfile.toml
diff --git a/recipes/incoming-cosmic/pipewire.toml b/recipes/pipewire.toml
similarity index 100%
rename from recipes/incoming-cosmic/pipewire.toml
rename to recipes/pipewire.toml
diff --git a/recipes/incoming-cosmic/pulseaudio.toml b/recipes/pulseaudio.toml
similarity index 100%
rename from recipes/incoming-cosmic/pulseaudio.toml
rename to recipes/pulseaudio.toml
diff --git a/scripts/qorpa/qorpa-preflight.sh b/scripts/qorpa/qorpa-preflight.sh
index 52f36ff6..5321e3cf 100755
--- a/scripts/qorpa/qorpa-preflight.sh
+++ b/scripts/qorpa/qorpa-preflight.sh
@@ -149,7 +149,12 @@ if [ -n "$RU" ] && [ -n "$RG" ] && [ "$herramienta_ok" = 1 ]; then
   d="$(mktemp -d)"; mkfifo "$d/listo" "$d/adelante"
   set -- $RU; ui=$1; uc=$2
   set -- $RG; gi=$1; gc=$2
-  timeout 20 unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" &
+  # SIN `timeout` acá, y no es un descuido: `timeout` FORKEA, así que `$!` sería SU pid y no el
+  # del proceso que entró al namespace ⇒ newuidmap apuntaría al proceso equivocado y el kernel
+  # respondería "Operation not permitted" — un falso negativo que se lee igual que un fallo real.
+  # (Costó una medición: el guardián decía que subuid no andaba cuando a mano andaba.)
+  # El corte por tiempo lo ponen los `timeout` de los fifos de abajo.
+  unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" &
   hijo=$!
   if timeout 10 head -c 1 "$d/listo" >/dev/null 2>&1; then
     err="$( { newuidmap "$hijo" 0 "$(id -u)" 1 1 "$ui" "$uc" && \