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 1506Shell 186Rust 107Markdown 983639 Definitions

la imagen USB, para cuando el salto no se puede — y tres bugs que sólo se ven por ese camino

El salto pide tres cosas del kernel que ya corre (CONFIG_KEXEC_FILE, root, Secure Boot sin
lockdown) y cualquiera puede faltar. `metal check` lo dice y manda al USB; hasta hoy el USB no
existía. Ahora se publica en takana-os.net/descargas y es EL MISMO instalador por otro camino:
mismo árbol, mismo censo, mismas guardas. El árbol se arma una sola vez (TREE_ONLY en el
generador de siempre) y tiene dos medios — cpio comprimido para el salto, ext4 para el USB.

⚠ El instalador NO va en la ESP, y no es un capricho: el cuelgue del primer viaje a metal fue
un initrd de ~100 MiB cargado por el EFI-stub (ADR 0010 paso 2) y el del instalador son 122.
El USB usa el patrón que SÍ está probado, el de un disco instalado: ESP con el kernel y un
pivote de 736 KiB → findfs LABEL=takana-medio → switch_root. La etiqueta no es `hammer-root` a
propósito: si el medio se llamara igual que la raíz de un sistema instalado, el pivote podría
montar el disco del usuario en vez del USB, y el que pierde es el disco.

Tres defectos que sólo aparecen por este camino, los tres del lado silencioso:

1. LA PREGUNTA SE IBA A UN SITIO QUE NO EXISTE. /dev/console es la ÚLTIMA `console=`, y el
   cmdline HORNEADO pone tty0 primero y ttyS0 último ⇒ arrancado de un USB, el censo y la
   pregunta salen por el serial. En un portátil sin puerto serie eso es una pantalla muerta y un
   instalador que parece colgado esperando una tecla que nadie puede escribir. Ahora se elige la
   consola mirando el vtconsole — y no basta con que tty0 EXISTA: con `console=tty0` el kernel lo
   registra igual en una máquina sin pantalla, con el driver `dummy` detrás, o sea un dispositivo
   que acepta todo y no muestra nada. `takana.console=` fuerza, y se dice en voz alta por los dos
   lados para el caso contrario (una máquina sin monitor, por serie).
   Y de paso: el cmdline del SALTO tenía tty0 primero. También mal, y también arreglado.

2. EL MEDIO SE OFRECÍA A SÍ MISMO. Arrancado de un USB, la raíz viva es el propio medio: el
   censo lo marcaba ⚡ RAIZ_VIVA y el menú lo aceptaba. Elegirlo es formatear el instalador
   mientras corre. Ahora es NO TOCAR, «es el MEDIO desde el que arrancaste».

3. Y EL DISCRIMINADOR ENTRE MEDIO Y SALTO ESTABA MAL. Miraba `rdinit=` en /proc/cmdline; con
   CONFIG_CMDLINE_BOOL y sin OVERRIDE el cmdline horneado se CONCATENA con el del arranque, así
   que `rdinit=` aparece SIEMPRE, también tras un kexec. El síntoma fue benigno de casualidad
   —un mensaje raro— pero la consecuencia real era que por el salto la raíz viva del usuario
   habría quedado marcada «es el MEDIO» y NO SE HABRÍA PODIDO ELEGIR: justo el caso que el salto
   existe para resolver. Ahora lo dice el pivote por el ENTORNO, que sobrevive al exec de
   switch_root, y los dos arneses se controlan mutuamente.

`scripts/instalador-usb-test.sh --instalar` arranca por FIRMWARE de verdad (OVMF por pflash,
sin -kernel: es la única forma de probar la tabla, la ESP, el stub y el nombre
\initramfs.cpio.gz que el cmdline horneado exige), CONTESTA EL MENÚ tecleando por el serial y
comprueba el disco destino. 12 en verde. Corre con -vga none a propósito: así se ejercita la
rama «sin pantalla ⇒ al serial»; la de pantalla es la del usuario.

El arnés del salto sigue en 13 verde con el .run rehecho.

4148ba4e02310090bb3c844c5942fe7e0251f106 · by Sergio · (29 d ago) · parent ff33b8eb8 · browse the tree at this commit

Semantic delta · 1

crates/takana-cli/src/metal.rs

7 files · +451 −8

diff --git a/crates/takana-cli/src/metal.rs b/crates/takana-cli/src/metal.rs
index ec15c2b1..dd98c886 100644
--- a/crates/takana-cli/src/metal.rs
+++ b/crates/takana-cli/src/metal.rs
@@ -41,9 +41,14 @@ use crate::kernel_ab;
 /// Nombres con los que viaja el payload. Se buscan en ese orden dentro del directorio del paquete.
 const INITRD: &[&str] = &["takana-instalador.cpio.gz", "instalador.cpio.gz", "initramfs.cpio.gz"];
 const KERNEL: &[&str] = &["kernel/boot/bzImage", "bzImage", "boot/bzImage", "kernel"];
-/// La consola por defecto pide las DOS: `tty0` es la pantalla del que instala y `ttyS0` es la
-/// única forma de ver por qué murió cuando no llega a pintar nada.
-const CONSOLA: &str = "console=tty0 console=ttyS0,115200";
+/// La consola por defecto pide las DOS: `ttyS0` es la única forma de ver por qué murió cuando no
+/// llega a pintar nada, y `tty0` es la pantalla del que instala.
+///
+/// ⚠ **El ORDEN no es cosmético: el kernel hace `/dev/console` = la ÚLTIMA `console=`.** Con `tty0`
+/// primero, `/dev/console` queda en el SERIAL — y el instalador interactivo, que lee y escribe por
+/// ahí, sale por un puerto que un portátil no tiene. La pantalla se queda en el último printk y el
+/// arranque parece colgado. `tty0` va ÚLTIMO.
+const CONSOLA: &str = "console=ttyS0,115200 console=tty0";
 
 #[derive(Subcommand)]
 pub enum MetalCmd {
diff --git a/scripts/instalador-initramfs.sh b/scripts/instalador-initramfs.sh
index b972cf75..b6e606a4 100755
--- a/scripts/instalador-initramfs.sh
+++ b/scripts/instalador-initramfs.sh
@@ -126,7 +126,24 @@ if [ -d /sys/firmware/efi ]; then
     mkdir -p /sys/firmware/efi/efivars 2>/dev/null || true
     mount -t efivarfs efivarfs /sys/firmware/efi/efivars 2>/dev/null || true
 fi
-log "TAKANA-INSTALADOR: arrancado en RAM. El sistema viejo YA NO CORRE: su disco está libre."
+# ── ¿POR DÓNDE SE LLEGÓ? ──────────────────────────────────────────────────────────────────────
+# Lo dice el pivote del medio, que exporta `TAKANA_MEDIO=1` antes de su `switch_root` — y el
+# entorno sobrevive al exec. No es un detalle: la diferencia cambia QUÉ ES LA RAÍZ VIVA (por el
+# salto, el sistema del usuario; por el medio, el propio USB) y con eso qué se ofrece formatear.
+#
+# ⚠ **La primera versión miraba `rdinit=` en /proc/cmdline y estaba MAL.** Con `CONFIG_CMDLINE_BOOL`
+# y sin `CONFIG_CMDLINE_OVERRIDE`, el cmdline horneado del kernel se CONCATENA con el que pasa quien
+# lo arranca: `rdinit=` aparece SIEMPRE, también tras un kexec, así que el instalador se creía en un
+# medio estando en el salto. Medido el 2026-09-23 — y el síntoma fue benigno de casualidad (un
+# mensaje raro), pero la consecuencia real era que la raíz viva del usuario habría quedado marcada
+# «es el MEDIO desde el que arrancaste» y NO SE HABRÍA PODIDO ELEGIR, que es justo el caso que el
+# salto existe para resolver.
+MEDIO="${TAKANA_MEDIO:-}"
+if [ -n "$MEDIO" ]; then
+    log "TAKANA-INSTALADOR: arrancado desde el MEDIO de instalación. Ningún disco de esta máquina está en uso."
+else
+    log "TAKANA-INSTALADOR: arrancado en RAM. El sistema viejo YA NO CORRE: su disco está libre."
+fi
 [ -d /sys/firmware/efi/efivars ] && log "TAKANA-INSTALADOR: firmware UEFI visible (efivarfs montado)" \
     || log "TAKANA-INSTALADOR: sin efivarfs ⇒ no se registrará entrada NVRAM (BIOS, o kernel sin EFI)"
 
@@ -153,7 +170,7 @@ cd /instalador
 #   3. NADA ⇒ modo interactivo: el censo del terreno y una pregunta
 # El 1 es lo que hace que este mismo initramfs sirva en cualquier máquina: quien salta por kexec
 # pasa la elección por la cmdline, y quien arranca de un USB la edita en el menú del loader.
-ARGS=""; YES=""
+ARGS=""; YES=""; CONSOLA_PEDIDA=""
 for _w in $(cat /proc/cmdline 2>/dev/null); do
     case "$_w" in
         takana.root=*) ARGS="$ARGS --root-part ${_w#takana.root=}" ;;
@@ -161,8 +178,38 @@ for _w in $(cat /proc/cmdline 2>/dev/null); do
         takana.disk=*) ARGS="$ARGS --disk ${_w#takana.disk=}" ;;
         takana.free)   ARGS="$ARGS --use-free-space" ;;
         takana.yes)    YES=1 ;;
+        takana.console=*) CONSOLA_PEDIDA="${_w#takana.console=}" ;;
     esac
 done
+# ── DÓNDE SE PREGUNTA ─────────────────────────────────────────────────────────────────────────
+# ⚠ `/dev/console` es la ÚLTIMA `console=` del cmdline, y **el cmdline HORNEADO del kernel pone
+# `tty0` PRIMERO y `ttyS0` último** ⇒ arrancado de un USB, `/dev/console` es el SERIAL. En un
+# portátil sin puerto serie eso significa que el censo y la pregunta salen por un sitio que no
+# existe: pantalla muerta y un instalador que parece colgado esperando una tecla que nadie puede
+# escribir. Por el salto el cmdline lo ponemos nosotros y `tty0` va último, pero por USB no se
+# puede elegir sin recompilar el kernel.
+#
+# Regla: si hay una VT de verdad, se pregunta en la PANTALLA. Y se dice en voz alta por los dos
+# lados, porque el caso contrario —una máquina sin monitor, administrada por serie— existe y tiene
+# que poder corregirlo sin adivinar: `takana.console=/dev/ttyS0` en la cmdline.
+# ⚠ Y no alcanza con que `tty0` EXISTA: con `console=tty0` en el cmdline el kernel lo registra
+# igual en una máquina sin pantalla, con el driver `dummy` detrás — o sea un dispositivo que acepta
+# todo lo que le escribas y no lo muestra en ningún lado. La pregunta se iría a un agujero. Lo que
+# distingue una pantalla de verdad es el vtconsole: `dummy device` contra `frame buffer device` o
+# `VGA+`. Medido en QEMU, que da las dos según se le pida `-vga std` o `-vga none`.
+hay_pantalla() {
+    for _v in /sys/class/vtconsole/vtcon*/name; do
+        [ -r "$_v" ] || continue
+        grep -qi 'dummy' "$_v" || return 0
+    done
+    return 1
+}
+CONSOLA=/dev/console
+if [ -n "$CONSOLA_PEDIDA" ]; then
+    CONSOLA="$CONSOLA_PEDIDA"
+elif [ -c /dev/tty0 ] && hay_pantalla; then
+    CONSOLA=/dev/tty0
+fi
 [ -z "$ARGS" ] && [ -s /instalador/args ] && ARGS=$(cat /instalador/args)
 log "TAKANA-INSTALADOR: takana-install-partition.sh ${ARGS:-(sin parámetros ⇒ interactivo)}"
 # Las herramientas viajan en /bin: el instalador las toma por TOOL_<binario> en vez de buscarlas en
@@ -170,6 +217,9 @@ log "TAKANA-INSTALADOR: takana-install-partition.sh ${ARGS:-(sin parámetros ⇒
 export TOOL_mke2fs=/bin/mke2fs TOOL_sfdisk=/bin/sfdisk TOOL_mmd=/bin/mmd \
        TOOL_mcopy=/bin/mcopy TOOL_mdir=/bin/mdir
 export STAGE=/payload/stage STAGE_LISTO=1 KERNEL=/payload/bzImage HAMMER=/bin/takana
+# El instalador necesita saberlo para clasificar bien la raíz viva: por el salto es el sistema del
+# usuario (se puede reemplazar); por el medio es el propio USB (no se toca nunca).
+[ -n "$MEDIO" ] && export TAKANA_MEDIO=1
 # ⚠ **El estado de un PIPE es el del ÚLTIMO comando, no el del primero.** Esto era
 # `if sh instalador… | tee …; then OK else rescate fi` y el `if` evaluaba el `tee`, que siempre sale
 # 0 ⇒ **el instalador falló, no formateó NADA, y esta pantalla dijo «instalado»**. Medido en QEMU el
@@ -187,8 +237,10 @@ else
     # Interactivo: la consola del kernel es la pantalla (tty0 va ÚLTIMO en la cmdline del salto),
     # así que entrada y salida van ahí y el instalador ve una terminal de verdad — sin eso su
     # `[ -t 0 ]` sale falso y se niega a preguntar.
-    log "TAKANA-INSTALADOR: sin autorización desatendida ⇒ el censo y la pregunta salen por la consola"
-    sh /instalador/takana-install-partition.sh $ARGS < /dev/console > /dev/console 2>&1
+    log "TAKANA-INSTALADOR: el censo y la pregunta salen por $CONSOLA"
+    log "TAKANA-INSTALADOR: si estás mirando esto por el puerto serie y la pregunta no aparece acá,"
+    log "                   rearrancá con  takana.console=/dev/ttyS0  en la cmdline."
+    sh /instalador/takana-install-partition.sh $ARGS < "$CONSOLA" > "$CONSOLA" 2>&1
     echo $? > /rc
 fi
 if [ "$(cat /rc 2>/dev/null || echo 1)" = "0" ]; then
@@ -202,6 +254,14 @@ chmod +x "$IRD_TREE/init"
 
 # ── 5) empaquetar ──────────────────────────────────────────────────────────────────────────────
 mkdir -p "$(dirname "$OUT")"
+# `TREE_ONLY=1` corta acá: el ÁRBOL es la pieza, y tiene dos medios. Comprimido en un cpio viaja
+# por kexec y dentro del `.run`; escrito en un ext4 es el USB, que existe para las máquinas donde el
+# salto no se puede. Duplicar el armado del árbol en el script del USB sería tener dos instaladores
+# que se parecen — y el que se olvide de actualizar es el que alguien va a arrancar.
+if [ -n "${TREE_ONLY:-}" ]; then
+    echo "==> TREE_ONLY: árbol listo en $IRD_TREE — no se comprime"
+    exit 0
+fi
 ( cd "$IRD_TREE" && find . -print0 | "$CPIO" --null -o -H newc --owner=root:root 2>/dev/null | "$GZIP" -1 ) > "$OUT"
 echo "==> $OUT: $(du -h "$OUT" | cut -f1)"
 echo
diff --git a/scripts/instalador-usb-test.sh b/scripts/instalador-usb-test.sh
new file mode 100755
index 00000000..d319f530
--- /dev/null
+++ b/scripts/instalador-usb-test.sh
@@ -0,0 +1,151 @@
+#!/bin/sh
+# instalador-usb-test.sh — el USB, arrancado por FIRMWARE UEFI de verdad (OVMF), no por `-kernel`.
+#
+# ══ QUÉ PRUEBA ════════════════════════════════════════════════════════════════════════════════
+# La cadena entera del medio, que es lo que el salto NO ejercita:
+#
+#   firmware UEFI → \EFI\BOOT\BOOTX64.EFI (EFI-stub) → initramfs de PIVOTE
+#                 → findfs LABEL=takana-medio → switch_root → el instalador
+#
+# Con `-kernel` QEMU salta el firmware entero y no probaría ni la tabla, ni la ESP, ni el stub, ni
+# el nombre `\initramfs.cpio.gz` que el cmdline horneado exige. Por eso acá va OVMF por `pflash` y
+# **sin `-kernel`**: la firmware busca sola la ruta de medios removibles.
+#
+# ⚠ `-vga none`. No es por ahorrar: con pantalla, el instalador manda su pregunta a `tty0` —que es
+# lo correcto en un portátil— y por serie no se vería nada. Sin pantalla, el `vtconsole` es `dummy`,
+# el instalador lo detecta y se va al serial, que es donde este arnés puede mirar. Las dos ramas de
+# esa decisión se ejercitan: ésta acá, y la de pantalla en el metal del usuario.
+#
+# Uso:  scripts/instalador-usb-test.sh [--conservar]
+# Vars: WORKER (def dev.gioser.net) · IMG (el USB) · P (dir de trabajo)
+set -eu
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
+WORKER="${WORKER:-dev.gioser.net}"
+IMG="${IMG:-work/takana-instalador-usb.img}"
+P="${P:-work/.usb-test}"
+REMOTO="/tmp/usb-test-$$"
+CONSERVAR=""; INSTALAR="${INSTALAR:-}"
+for a in "$@"; do case "$a" in
+    --conservar) CONSERVAR=1 ;;
+    # Además de llegar al censo, CONTESTARLO: se teclea la opción por el serial y se comprueba
+    # después, sobre el disco, que se instaló donde se dijo. La opción va fija porque el layout lo
+    # fija este arnés, y el censo se comprueba ANTES de contestar para que un cambio de layout falle
+    # en voz alta en vez de elegir otra partición en silencio.
+    --instalar) INSTALAR=1 ;;
+    *) echo "opción desconocida: $a" >&2; exit 2 ;;
+esac; done
+
+[ -r "$IMG" ] || { echo "no encuentro $IMG (armalo con scripts/instalador-usb.sh)" >&2; exit 1; }
+
+. "$(dirname "$0")/lib/herramientas-desde-store.sh"
+SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk)   || exit 1
+MFORMAT=$(herramienta mtools usr/bin/mformat mformat) || exit 1
+MMD=$(herramienta     mtools usr/bin/mmd     mmd)     || exit 1
+MCOPY=$(herramienta   mtools usr/bin/mcopy   mcopy)   || exit 1
+GZIP=$(herramienta    gzip bin/gzip gzip)             || exit 1
+
+FALLOS=0
+ok()   { echo "  ✓ $*"; }
+falla(){ echo "  ✗ $*" >&2; FALLOS=$(( FALLOS + 1 )); }
+
+mkdir -p "$P"
+D="$P/destino.img"
+echo "==> disco DESTINO con vecinos de verdad (ESP con Windows + 2400M libre + hueco)"
+rm -f "$D"; truncate -s 4096M "$D"
+"$SFDISK" --quiet "$D" <<'EOF'
+label: gpt
+start=2048,   size=262144,  type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="EFI System Partition"
+start=264192, size=4915200, type=linux, name="destino"
+EOF
+rm -f "$P/esp.img"; truncate -s 128M "$P/esp.img"
+"$MFORMAT" -F -v VECINO -i "$P/esp.img" ::
+"$MMD"   -i "$P/esp.img" ::/EFI ::/EFI/Microsoft ::/EFI/Microsoft/Boot
+head -c 400000 /dev/urandom > "$P/ajeno.bin"
+"$MCOPY" -i "$P/esp.img" "$P/ajeno.bin" ::/EFI/Microsoft/Boot/bootmgfw.efi
+dd if="$P/esp.img" of="$D" bs=1M seek=1 conv=notrunc status=none
+
+echo "==> subiendo al worker ($WORKER)"
+ssh -o BatchMode=yes "root@$WORKER" "mkdir -p $REMOTO"
+for f in "$IMG" "$D"; do
+    "$GZIP" -1 -c "$f" | ssh -o BatchMode=yes "root@$WORKER" "gzip -d -c > $REMOTO/$(basename "$f")"
+done
+
+echo "==> arrancando por FIRMWARE (OVMF, sin -kernel)"
+LOG="$P/serie.log"
+# La entrada del menú se TECLEA por el serial. El `sleep` de delante no es pereza: si los
+# caracteres llegan antes de que el instalador abra la consola se pierden, y el menú se queda
+# esperando una tecla que ya pasó.
+if [ -n "$INSTALAR" ]; then
+    TECLAS="(sleep 40; printf '4\nsi\n'; sleep 300)"
+else
+    TECLAS="cat /dev/null"
+fi
+ssh -o BatchMode=yes "root@$WORKER" "cd $REMOTO && \
+    OV=\$(ls /usr/share/edk2/ovmf/OVMF_CODE.fd /usr/share/ovmf/x64/OVMF_CODE.fd 2>/dev/null | head -1) && \
+    OVV=\$(ls /usr/share/edk2/ovmf/OVMF_VARS.fd /usr/share/ovmf/x64/OVMF_VARS.fd 2>/dev/null | head -1) && \
+    cp \"\$OVV\" vars.fd && \
+    $TECLAS | timeout 420 qemu-system-x86_64 -m 4096 -smp 2 -nographic -vga none -no-reboot \
+      -drive if=pflash,format=raw,unit=0,readonly=on,file=\"\$OV\" \
+      -drive if=pflash,format=raw,unit=1,file=vars.fd \
+      -drive file=$(basename "$IMG"),format=raw,if=virtio \
+      -drive file=destino.img,format=raw,if=virtio" > "$LOG" 2>&1 || true
+if [ -n "$INSTALAR" ]; then
+    echo "==> trayendo el disco destino de vuelta"
+    ssh -o BatchMode=yes "root@$WORKER" "gzip -c $REMOTO/destino.img" > "$P/destino-despues.img.gz"
+    "$GZIP" -d -c "$P/destino-despues.img.gz" > "$P/destino-despues.img"
+fi
+echo "    log: $LOG ($(wc -l < "$LOG") líneas)"
+[ -n "$CONSERVAR" ] || ssh -o BatchMode=yes "root@$WORKER" "rm -rf $REMOTO"
+
+echo "==> comprobaciones (la CADENA, que es lo que el salto no prueba)"
+limpio() { sed 's/\x1b\[[0-9;]*[a-zA-Z]//g; s/\r//g' "$LOG"; }
+limpio > "$P/serie.txt"
+grep -q 'TAKANA-MEDIO: buscando el medio' "$P/serie.txt" \
+    && ok "la firmware lanzó BOOTX64.EFI y el EFI-stub cargó el pivote" \
+    || falla "no llegó al pivote: la firmware no arrancó el medio"
+grep -q 'TAKANA-MEDIO-OK: switch_root' "$P/serie.txt" \
+    && ok "el pivote encontró LABEL=takana-medio y pivotó" \
+    || falla "el pivote no encontró el medio"
+grep -q 'arrancado desde el MEDIO' "$P/serie.txt" \
+    && ok "el instalador sabe que vino del medio (y no dice «el sistema viejo ya no corre»)" \
+    || falla "el instalador no distinguió medio de salto"
+grep -q 'la pregunta salen por /dev/ttyS0\|salen por /dev/console' "$P/serie.txt" \
+    && ok "sin pantalla, la pregunta se fue al serial (vtconsole dummy detectado)" \
+    || falla "la consola elegida no fue la serie estando sin pantalla"
+grep -q 'el terreno de esta máquina' "$P/serie.txt" \
+    && ok "el censo del terreno salió por la consola" || falla "no salió el censo"
+# La prueba de que el medio NO se ofrece a sí mismo: su ext4 es la raíz viva y tiene que estar
+# marcada NO TOCAR, no ⚡. Ofrecerla sería ofrecer formatear el instalador mientras corre.
+grep -q 'es el MEDIO desde el que arrancaste' "$P/serie.txt" \
+    && ok "el medio NO se ofrece a sí mismo como destino" \
+    || falla "el medio no quedó marcado como intocable"
+grep -q 'NTFS\|NO TOCAR' "$P/serie.txt" && ok "hay particiones marcadas NO TOCAR" || falla "el censo no marcó nada"
+
+# La opción que se va a teclear tiene que ser la que creemos, y se comprueba ANTES de dar por bueno
+# nada: si el layout cambió, «4» ya no es el destino y el test tiene que decirlo.
+if [ -n "$INSTALAR" ]; then
+    grep -qE '^ +4 +/dev/vdb2 .*se puede usar' "$P/serie.txt" \
+        && ok "la opción 4 del menú es /dev/vdb2 (la que se tecleó)" \
+        || falla "la opción 4 no es /dev/vdb2: el layout cambió y se tecleó a ciegas"
+    grep -q 'takana instalada en' "$P/serie.txt" && ok "el instalador dijo que instaló" \
+        || falla "el instalador no llegó a instalar (¿las teclas no entraron por el serial?)"
+    A="$P/destino-despues.img"
+    BL2=$(herramienta util-linux sbin/blkid blkid) || exit 1
+    dd if="$A" of="$P/p2.img" bs=1M skip=129 count=2400 status=none
+    "$BL2" "$P/p2.img" 2>/dev/null | grep -q 'LABEL="hammer-root"' \
+        && ok "el destino quedó ext4 con LABEL=hammer-root" \
+        || falla "el destino no tiene LABEL=hammer-root: $("$BL2" "$P/p2.img" 2>&1 | head -1)"
+    MD=$(herramienta mtools usr/bin/mdir mdir) || exit 1
+    "$MD" -i "$A@@1048576" ::/EFI/takana 2>/dev/null < /dev/null | grep -qi 'takanax64' \
+        && ok "la ESP del destino tiene \\EFI\\takana\\takanax64.efi" || falla "la ESP del destino quedó sin nuestro cargador"
+    MC=$(herramienta mtools usr/bin/mcopy mcopy) || exit 1
+    rm -f "$P/vecino-despues.bin"
+    "$MC" -i "$A@@1048576" ::/EFI/Microsoft/Boot/bootmgfw.efi "$P/vecino-despues.bin" 2>/dev/null < /dev/null || true
+    [ "$(sha256sum "$P/ajeno.bin" | cut -d' ' -f1)" = "$(sha256sum "$P/vecino-despues.bin" 2>/dev/null | cut -d' ' -f1)" ] \
+        && ok "el bootmgfw.efi del vecino quedó byte a byte igual" || falla "el vecino de la ESP cambió"
+    rm -f "$P/p2.img" "$P/destino-despues.img" "$P/destino-despues.img.gz"
+fi
+
+echo
+if [ "$FALLOS" = 0 ]; then echo "VERDE — el USB arranca por firmware y llega al instalador."; else
+    echo "ROJO — $FALLOS comprobación(es). Log: $P/serie.txt" >&2; exit 1; fi
diff --git a/scripts/instalador-usb.sh b/scripts/instalador-usb.sh
new file mode 100755
index 00000000..b28c9e22
--- /dev/null
+++ b/scripts/instalador-usb.sh
@@ -0,0 +1,169 @@
+#!/bin/sh
+# instalador-usb.sh — la imagen USB del instalador, para cuando el salto NO se puede.
+#
+# ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════
+# `takana metal install` reemplaza el Linux que corre sin USB, saltando con `kexec`. Eso pide tres
+# cosas del kernel de ESA máquina —`CONFIG_KEXEC_FILE`, ser root y que Secure Boot no esté en
+# lockdown— y cualquiera de las tres puede faltar. Cuando falta, `metal check` lo dice y manda acá.
+#
+# **El instalador es el mismo**: el mismo árbol, el mismo censo, las mismas guardas. Lo único que
+# cambia es cómo se llega a él.
+#
+# ── EL DISEÑO, Y LA MEDICIÓN QUE LO OBLIGA ────────────────────────────────────────────────────
+# La forma obvia sería poner el initramfs del instalador en la ESP y dejar que el EFI-stub lo
+# cargue. **No se puede, y está medido:** el cuelgue del primer viaje a metal fue exactamente eso,
+# un initrd de ~100 MiB cargado por el stub (ADR 0010 paso 2). El del instalador son ~122 MiB.
+#
+# Entonces el USB usa el MISMO patrón que un disco instalado, que sí está probado:
+#
+#   firmware → \EFI\BOOT\BOOTX64.EFI (= bzImage, EFI-stub)
+#            → initramfs de PIVOTE (~800 KiB, el que sí entra)
+#            → findfs LABEL=takana-medio → switch_root
+#            → /init del instalador, ya sobre un filesystem de verdad
+#
+# ⚠ La etiqueta es `takana-medio` y **no** `hammer-root` a propósito: si el medio se llamara igual
+# que la raíz de un sistema instalado, el pivote podría montar el disco del usuario en vez del USB
+# —o al revés— y el que pierde es el disco. Dos cosas distintas, dos nombres distintos.
+#
+# Uso:  scripts/instalador-usb.sh [--salida <img>]
+# Vars: ROOTFS KERNEL (como instalador-paquete.sh) · IMG · ESP_MB (def 96) · HOLGURA_MB (def 128)
+#
+# Grabarlo:  sudo dd if=work/takana-instalador-usb.img of=/dev/sdX bs=4M oflag=sync status=progress
+set -eu
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
+
+IMG="${IMG:-work/takana-instalador-usb.img}"
+ESP_MB="${ESP_MB:-96}"
+HOLGURA_MB="${HOLGURA_MB:-128}"
+ETIQUETA="takana-medio"
+[ "${1:-}" = "--salida" ] && { IMG="${2:?}"; shift 2; }
+
+. "$(dirname "$0")/lib/herramientas-desde-store.sh"
+SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk)   || exit 1
+MKE2FS=$(herramienta e2fsprogs  sbin/mke2fs mke2fs)   || exit 1
+MFORMAT=$(herramienta mtools usr/bin/mformat mformat) || exit 1
+MMD=$(herramienta     mtools usr/bin/mmd     mmd)     || exit 1
+MCOPY=$(herramienta   mtools usr/bin/mcopy   mcopy)   || exit 1
+MDIR=$(herramienta    mtools usr/bin/mdir    mdir)    || exit 1
+CPIO=$(herramienta    libarchive usr/bin/bsdcpio cpio) || exit 1
+GZIP=$(herramienta    gzip bin/gzip gzip)             || exit 1
+
+# ── 1) el árbol del instalador — el MISMO que viaja por kexec ─────────────────────────────────
+TREE="${IRD_TREE:-work/.instalador-initramfs-tree}"
+echo "==> árbol del instalador (TREE_ONLY: lo arma el generador de siempre)"
+TREE_ONLY=1 IRD_TREE="$TREE" ROOTFS="${ROOTFS:-}" KERNEL="${KERNEL:-}" ./scripts/instalador-initramfs.sh
+[ -x "$TREE/init" ] && [ -d "$TREE/payload/stage/root" ] || {
+    echo "el árbol no quedó completo en $TREE" >&2; exit 1; }
+KERNEL="${KERNEL:-$TREE/payload/bzImage}"
+[ -r "$KERNEL" ] || { echo "no encuentro el bzImage (set KERNEL=)" >&2; exit 1; }
+
+TREE_MB=$(du -sm "$TREE" | cut -f1)
+EXT_MB=$(( TREE_MB + HOLGURA_MB ))
+TOTAL_MB=$(( ESP_MB + EXT_MB + 2 ))
+echo "    árbol ${TREE_MB} MiB · ext4 ${EXT_MB} MiB · imagen ${TOTAL_MB} MiB"
+
+# ── 2) el initramfs de PIVOTE: lo único que carga el EFI-stub ─────────────────────────────────
+# Es hermano del pivote de `install-image-efi.sh` y comparte sus dos lecciones caras: el log va a
+# tty0 **y** al serial porque el cmdline horneado deja /dev/console en el serial, y la espera del
+# disco es LARGA porque el USB enumera de forma asíncrona y tarda segundos (en QEMU nunca se ve).
+P="work/.instalador-usb"; rm -rf "$P"; mkdir -p "$P/pivote/bin" "$P/pivote/dev" "$P/pivote/proc" \
+    "$P/pivote/sys" "$P/pivote/newroot"
+BUSYBOX="${BUSYBOX:-$TREE/bin/busybox}"
+[ -r "$BUSYBOX" ] || { echo "no encuentro busybox en $BUSYBOX" >&2; exit 1; }
+cp "$BUSYBOX" "$P/pivote/bin/busybox"; chmod 0755 "$P/pivote/bin/busybox"
+for a in sh mount umount findfs switch_root mkdir echo sleep ls cat setsid dmesg blkid; do
+    ln -sf busybox "$P/pivote/bin/$a"
+done
+cat > "$P/pivote/init" <<'PIVOTE'
+#!/bin/busybox sh
+# Pivote del MEDIO de instalación: encuentra el USB por etiqueta y le entrega el control al
+# instalador. No toca ningún disco: sólo monta el medio del que se arrancó.
+export PATH=/bin
+mount -t proc     proc /proc 2>/dev/null || true
+mount -t sysfs    sys  /sys  2>/dev/null || true
+mount -t devtmpfs dev  /dev  2>/dev/null || true
+# El cmdline horneado deja /dev/console en el SERIAL: sin escribir explícitamente en tty0, en un
+# portátil sin puerto serie todo esto es invisible y el arranque parece congelado.
+log() { for d in /dev/tty0 /dev/ttyS0; do echo "$*" > "$d" 2>/dev/null || true; done; }
+rescate() {
+    log "TAKANA-MEDIO-FALLO: $*"
+    log "   bloques visibles: $(ls /dev/sd* /dev/nvme* /dev/vd* /dev/mmcblk* 2>/dev/null | tr '\n' ' ')"
+    log "   shell de rescate en tty1 — probá: blkid ; dmesg | tail -30"
+    setsid -c sh -c 'exec /bin/sh </dev/tty1 >/dev/tty1 2>&1' >/dev/null 2>&1 &
+    exec sh
+}
+log "TAKANA-MEDIO: buscando el medio de instalación (LABEL=takana-medio)"
+# ⏱ 60 s y no 5: el bus USB enumera asíncrono —reset del hub, 1 s de settling por dispositivo, scan
+# SCSI— y tarda segundos. En QEMU el disco está desde el instante cero, así que esta carrera NO se
+# ve en validación: aparece la primera vez que alguien arranca de un pendrive de verdad.
+DEV=""; i=0
+while [ "$i" -lt 60 ]; do
+    DEV="$(findfs LABEL=takana-medio 2>/dev/null || true)"
+    [ -n "$DEV" ] && break
+    i=$(( i + 1 )); [ $(( i % 5 )) -eq 0 ] && log "TAKANA-MEDIO: esperando el medio… ${i}s (el USB tarda en enumerar)"
+    sleep 1
+done
+[ -n "$DEV" ] || rescate "no encuentro LABEL=takana-medio tras ${i}s"
+log "TAKANA-MEDIO: $DEV → montando"
+mount -t ext4 "$DEV" /newroot || rescate "no pude montar $DEV"
+[ -x /newroot/init ] || rescate "el medio no trae /init"
+log "TAKANA-MEDIO-OK: switch_root → el instalador"
+# El instalador tiene que SABER que vino de un medio: con eso su raíz viva es este USB —y no se
+# ofrece— mientras que por el salto la raíz viva es el sistema del usuario, que sí se reemplaza.
+# El entorno sobrevive al `exec` de switch_root, así que esto llega intacto al otro lado.
+export TAKANA_MEDIO=1
+exec switch_root /newroot /init
+PIVOTE
+chmod 0755 "$P/pivote/init"
+( cd "$P/pivote" && find . -print0 | "$CPIO" --null -o -H newc --owner=root:root 2>/dev/null | "$GZIP" -9 ) > "$P/initramfs.cpio.gz"
+echo "==> pivote: $(( $(wc -c < "$P/initramfs.cpio.gz") / 1024 )) KiB"
+
+# ── 3) la tabla ───────────────────────────────────────────────────────────────────────────────
+rm -f "$IMG"; truncate -s "${TOTAL_MB}M" "$IMG"
+ESP_SECT=$(( ESP_MB * 2048 ))
+"$SFDISK" --quiet "$IMG" <<EOF
+label: gpt
+start=2048, size=$ESP_SECT, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="EFI System Partition"
+start=$(( 2048 + ESP_SECT )), type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="takana-medio"
+EOF
+
+# ── 4) la ESP, poblada SIN montarla ───────────────────────────────────────────────────────────
+# Se formatea aparte y se empalma en su offset: formatear in-situ con `@@` hace que el FAT declare
+# el tamaño del fichero ENTERO, y después `df` repite la mentira.
+"$MFORMAT" -F -v TAKANA -i "$P/esp.img" -C -T $(( ESP_MB * 2048 )) ::
+"$MMD"   -i "$P/esp.img" ::/EFI ::/EFI/BOOT ::/EFI/takana
+# Las DOS rutas al MISMO bzImage: la de vendor y la de medios removibles (ADR 0018 §3). De un USB
+# manda la segunda, que es la que el firmware busca sin que nadie registre nada en la NVRAM.
+"$MCOPY" -i "$P/esp.img" "$KERNEL" ::/EFI/takana/takanax64.efi
+"$MCOPY" -i "$P/esp.img" "$KERNEL" ::/EFI/BOOT/BOOTX64.EFI
+# El nombre NO es libre: el cmdline horneado del kernel dice `initrd=/initramfs.cpio.gz`.
+"$MCOPY" -i "$P/esp.img" "$P/initramfs.cpio.gz" ::/initramfs.cpio.gz
+for f in ::/EFI/takana/takanax64.efi ::/EFI/BOOT/BOOTX64.EFI ::/initramfs.cpio.gz; do
+    "$MDIR" -i "$P/esp.img" "$f" >/dev/null 2>&1 < /dev/null || {
+        echo "la ESP no quedó con $f" >&2; exit 1; }
+done
+dd if="$P/esp.img" of="$IMG" bs=1M seek=1 conv=notrunc status=none
+
+# ── 5) el ext4 con el árbol adentro, sin montar nada ──────────────────────────────────────────
+echo "==> ext4 «$ETIQUETA» con el instalador adentro (${EXT_MB} MiB, sin montar)"
+"$MKE2FS" -q -t ext4 -L "$ETIQUETA" -d "$TREE" "$P/medio.img" "${EXT_MB}M"
+dd if="$P/medio.img" of="$IMG" bs=1M seek=$(( ESP_MB + 1 )) conv=notrunc status=none
+
+# ── 6) el control: que la imagen diga lo que creemos ──────────────────────────────────────────
+echo "==> control"
+FALLOS=0
+ok()   { echo "  ✓ $*"; }
+falla(){ echo "  ✗ $*" >&2; FALLOS=$(( FALLOS + 1 )); }
+"$SFDISK" -d "$IMG" | grep -q 'C12A7328' && ok "la tabla trae una ESP" || falla "sin ESP en la tabla"
+"$MDIR" -i "$IMG@@1048576" ::/EFI/BOOT >/dev/null 2>&1 < /dev/null \
+    && ok "la ESP se lee desde su offset en la imagen" || falla "la ESP no se lee"
+dd if="$IMG" bs=1M skip=$(( ESP_MB + 1 )) count="$EXT_MB" status=none > "$P/verif-ext.img"
+BL=$(herramienta util-linux sbin/blkid blkid) || exit 1
+"$BL" "$P/verif-ext.img" 2>/dev/null | grep -q "LABEL=\"$ETIQUETA\"" \
+    && ok "el ext4 quedó etiquetado «$ETIQUETA»" || falla "el ext4 no tiene la etiqueta"
+rm -rf "$P/verif-ext.img" "$P/medio.img" "$P/esp.img"
+[ "$FALLOS" = 0 ] || exit 1
+
+echo
+echo "✓ $IMG  ($(( $(wc -c < "$IMG") / 1048576 )) MiB)"
+echo "  sudo dd if=$IMG of=/dev/sdX bs=4M oflag=sync status=progress"
diff --git a/scripts/takana-install-partition.sh b/scripts/takana-install-partition.sh
index bd5256fc..caba428b 100755
--- a/scripts/takana-install-partition.sh
+++ b/scripts/takana-install-partition.sh
@@ -276,6 +276,13 @@ clasificar() {
         BitLocker|bitlocker) echo "NO_TOCAR|BitLocker"; return ;;
     esac
     if [ "$_cl_mnt" = "/" ]; then
+        # ⚠ **La raíz viva no siempre es del usuario.** Arrancado desde un USB o una imagen, la raíz
+        # viva es EL MEDIO — y ofrecerlo como destino es ofrecer formatear el instalador mientras
+        # corre. El medio se sabe en el arranque (`TAKANA_MEDIO`, lo pone el `/init` mirando si el
+        # cmdline trae `rdinit=`), no se adivina acá.
+        if [ -n "${TAKANA_MEDIO:-}" ]; then
+            echo "NO_TOCAR|es el MEDIO desde el que arrancaste (el instalador corre desde acá)"; return
+        fi
         echo "RAIZ_VIVA|la raíz del sistema que está corriendo AHORA"; return
     fi
     if [ -n "$_cl_mnt" ]; then
diff --git a/web/takana-os.net/README.md b/web/takana-os.net/README.md
index f688d226..90ea4af7 100644
--- a/web/takana-os.net/README.md
+++ b/web/takana-os.net/README.md
@@ -60,11 +60,43 @@ el fichero de al lado, que se regenera junto al `.run`.
 ⚠ `/descargas` queda **fuera del `encode`** del vhost: el `.run` ya viene comprimido, y volver a
 comprimirlo por HTTP gasta CPU en cada descarga sin ahorrar un byte.
 
+### La imagen USB, para cuando el salto no se puede
+
+`takana-instalador-usb.img` (519 MiB) es **el mismo instalador por otro camino**: mismo árbol, mismo
+censo, mismas guardas. Se graba con `dd` y arranca por la ruta de medios removibles del firmware.
+
+⚠ **No lleva el instalador en la ESP, y no es un capricho:** el cuelgue del primer viaje a metal fue
+un initrd de ~100 MiB cargado por el EFI-stub ([ADR 0010](../../docs/adr/0010-arranque-grafo-mirada.md)
+paso 2), y el del instalador son ~122 MiB. El USB usa el patrón que **sí** está probado —el de un
+disco instalado—: la ESP lleva el kernel y un pivote de 736 KiB, y el pivote encuentra la ext4 por
+`LABEL=takana-medio` y hace `switch_root`. La etiqueta NO es `hammer-root` a propósito: si el medio
+se llamara igual que la raíz de un sistema instalado, el pivote podría montar el disco del usuario
+en vez del USB, y el que pierde es el disco.
+
+```sh
+scripts/instalador-usb.sh                   # arma work/takana-instalador-usb.img
+scripts/instalador-usb-test.sh --instalar   # lo arranca en OVMF y lo contesta por el serial
+cp work/takana-instalador-usb.img /work/sergio/descargas/
+( cd /work/sergio/descargas && sha256sum takana-instalador-usb.img > takana-instalador-usb.img.sha256 )
+```
+
 **Está probado de punta a punta, no sólo servido:** `VIA_RUN=1 scripts/metal-salto-test.sh` mete el
 fichero publicado en una VM con `linux-metal-kexec`, lo corre, y comprueba sobre el DISCO que se
 verificó solo, se desempaquetó, saltó e instaló — 13 comprobaciones, con el `bootmgfw.efi` del
 vecino byte a byte igual y la tabla de particiones sin cambiar.
 
+Y el USB, `scripts/instalador-usb-test.sh --instalar`: **arranca por firmware de verdad** (OVMF por
+`pflash`, sin `-kernel`, que es la única forma de probar la tabla, la ESP, el stub y el nombre
+`\initramfs.cpio.gz` que el cmdline horneado exige), **contesta el menú tecleando por el serial** y
+comprueba el disco destino. 12 en verde.
+
+⚠ El arnés corre con `-vga none`, y eso decide qué rama del código se prueba: sin pantalla el
+instalador detecta el `vtconsole` `dummy` y se va al serial. **Con** pantalla —el caso del usuario—
+la pregunta sale por `tty0`. Las dos ramas existen porque el cmdline HORNEADO del kernel pone `tty0`
+primero y `ttyS0` último, o sea que `/dev/console` es el serial: en un portátil sin puerto serie, sin
+esta detección, el censo y la pregunta se irían a un sitio que no existe y el arranque parecería
+colgado.
+
 ## Las dos direcciones que publica
 
 - **código** → `https://git.gioser.net/sergio/takana` (público; `git clone` por HTTPS)
@@ -140,7 +172,7 @@ contra el disco, y los dos que redirigen por destino.
 |---|---|---|
 | `takana-os.net`, `www` | el sitio | ✅ con certificado de Let's Encrypt (2026-09-21) |
 | `takana-os.net/repo/` | el repo de paquetes, el mismo `/srv/repo` | ✅ |
-| `takana-os.net/descargas/` | el instalador en UN fichero (`/work/sergio/descargas`) | ✅ (2026-09-23) |
+| `takana-os.net/descargas/` | el instalador en UN fichero, y la imagen USB (`/work/sergio/descargas`) | ✅ (2026-09-23) |
 | `takana.gioser.net` | **301 → `takana-os.net`** | ✅ |
 | `git.takana-os.net` | **301 → `git.gioser.net`** | ⏳ vhost puesto, **falta el registro A** |
 
diff --git a/web/takana-os.net/index.html b/web/takana-os.net/index.html
index b47e2d80..f96dd258 100644
--- a/web/takana-os.net/index.html
+++ b/web/takana-os.net/index.html
@@ -259,6 +259,25 @@ footer.pie{border-top:1px solid var(--linea);background:var(--papel);padding:2.6
 <pre><span class="p">$</span> curl -sO https://takana-os.net/descargas/takana-instalador.run.sha256
 <span class="p">$</span> sha256sum -c takana-instalador.run.sha256</pre>
 
+ <h3><span data-l="es">Y si esta máquina no puede saltar: el USB</span><span data-l="en">And if this machine cannot jump: the USB</span></h3>
+ <p>
+  <span data-l="es">El salto pide tres cosas del kernel que ya está corriendo, y cualquiera puede faltar: soporte de <code>kexec</code>, ser root, y que Secure Boot no esté bloqueando. Cuando falta alguna, el instalador lo dice y explica cuál. Para ese caso está la <strong>imagen USB</strong>, que es el mismo instalador llegando por otro camino — el mismo censo y las mismas guardas.</span>
+  <span data-l="en">The jump asks three things of the kernel already running, and any of them can be missing: <code>kexec</code> support, being root, and Secure Boot not getting in the way. When one is missing the installer says so and explains which. For that case there is the <strong>USB image</strong>, which is the same installer arriving by another route — the same survey and the same guards.</span>
+ </p>
+
+ <div class="direccion">
+  <p class="etiqueta"><span data-l="es">la imagen USB · 519 MiB · UEFI x86-64</span><span data-l="en">the USB image · 519 MiB · UEFI x86-64</span></p>
+  <a class="url" href="/descargas/takana-instalador-usb.img">takana-os.net/descargas/takana-instalador-usb.img</a>
+ </div>
+
+<pre><span class="p">$</span> curl -LO https://takana-os.net/descargas/takana-instalador-usb.img
+<span class="p">$</span> sudo dd if=takana-instalador-usb.img of=/dev/sdX bs=4M oflag=sync status=progress</pre>
+
+ <p class="nota">
+  <span data-l="es">Arranca por la ruta de medios removibles del firmware: no hay nada que registrar y no toca la NVRAM hasta que vos elegís instalar. El medio desde el que arrancaste <strong>nunca se ofrece como destino</strong>. Si la máquina no tiene pantalla y la estás administrando por puerto serie, arrancá con <code>takana.console=/dev/ttyS0</code>.</span>
+  <span data-l="en">It boots through the firmware's removable-media path: there is nothing to register and it does not touch NVRAM until you choose to install. The medium you booted from is <strong>never offered as a target</strong>. If the machine has no screen and you are driving it over a serial port, boot with <code>takana.console=/dev/ttyS0</code>.</span>
+ </p>
+
  <p class="nota">
   <span data-l="es">El fichero verifica su propio contenido antes de desempaquetarlo, y si la máquina no tiene con qué calcular un sha256 lo <em>dice</em> en vez de saltárselo en silencio. Con <code>--check</code> y <code>--survey</code> mira y no toca nada; con <code>--extraer DIR</code> sólo se desempaqueta, para leer antes de correr. Hace falta un firmware UEFI y una partición de al menos 2 GiB que se pueda perder; nada más se modifica.</span>
   <span data-l="en">The file verifies its own contents before unpacking them, and if the machine has no way to compute a sha256 it <em>says so</em> instead of skipping it silently. With <code>--check</code> and <code>--survey</code> it looks and touches nothing; with <code>--extraer DIR</code> it only unpacks, so you can read before running. It needs UEFI firmware and one partition of at least 2 GiB that you can afford to lose; nothing else is modified.</span>