fix(instalador): la cuenta de quien va a usar la maquina se pregunta antes de instalar
Una takana instalada no dejaba entrar ni administrar a nadie. Medido en metal el 14-oct: `sergio:!` + `root:!` + ningun `/etc/doas.conf`, y `su` contestando «must be suid to work properly». Son cuatro huecos distintos del camino de instalacion, no uno: 1. NADIE PREGUNTABA POR UNA CUENTA. `takana-install-partition` (el camino de --keep/--adopt, el mas comun) no pregunta nada: los usuarios salen de lib/usuarios-del-home.sh, que los cosecha del /home conservado con `!` en shadow — correcto de su parte, inventar una contrasena seria peor — y nadie, despues, la pedia. Y `takana-live-install` solo preguntaba por root, o sea una imagen de escritorio donde el greeter no puede autenticar a nadie. => scripts/lib/cuenta.sh, compartida por los dos instaladores (son DOS caminos y UNA pregunta, el mismo argumento que escribe usuarios-del-home.sh). Usuario, contrasena SHA-512, hogar, grupos video/input, `permit persist` y /etc/profile. Idempotente: a un usuario ya cosechado solo le pone su contrasena, SIN tocarle el uid — si se lo cambia, sus archivos dejan de ser suyos. Root queda cerrado si alguien puede escalar, y se pide obligatorio si no: una maquina sin ninguna de las dos no se administra. 2. NINGUN PERFIL DECLARABA EL BIT DE PRIVILEGIO de busybox, y once applets instalados lo necesitan: su, login, passwd, mount, umount, ping, ping6, traceroute, traceroute6, crontab y vlock. El encabezado de takana-core/src/setuid.rs lo habia previsto textualmente («hoy es LATENTE: el instalador solo pone contrasena de root, asi que no hay un humano no-root que lo choque») y el 14-oct lo hubo. El build trae CONFIG_FEATURE_SUID (medido con strings: la cadena «must be suid to work properly» solo existe si esta compilada), asi que los applets que no lo piden sueltan el privilegio en check_suid(). Metadato => el hash queda SELLADO, cero reforja de las 24 recetas que declaran busybox build-dep. 3. LA CARD QUE ARRANCA EL ESCRITORIO no nacia de ninguna receta: existia porque un script de parche la inyectaba en la semilla a mano, o sea que una instalacion de cero arranca el sistema y no arranca el escritorio. [[service]] en mirada-compositor (seatd + mirada, con backoff de arje en vez de un `sh -c` que contaba intentos), y el PATH de la Card incluye /usr/local/bin — sin eso el operador tenia claude instalado y la consola contestaba `claude: not found`. Los bin del hogar NO van en la Card: los agrega el compositor para quien inicia sesion, y clavarlos apuntaria al hogar de UNA persona en una imagen que instala cualquiera. 4. TRECE PAQUETES que el escritorio necesita no los declaraba ningun perfil, y vivian en una variable EXTRA de un script de otro repo: dejavu-fonts (sin un .ttf el compositor no pinta un solo rotulo, y se calla), fontconfig/freetype/libpng (su cadena), alsa-lib (pata-llimphi pide libasound: sin ella el riel no arranca), xkeyboard-config (la Card exporta XKB_CONFIG_ROOT a un directorio vacio), ncurses/bzip2, foot (ninguna terminal) y shuma-*. Es la misma fuga que cerraron las tres -shared: mirada-usb.sh cierra las librerias copiando del devfs de Alpine del lab, asi que el medio arranca y el hueco no se ve hasta que la imagen vive sola. Y de paso: los generadores del instalador copiaban UNA de las CUATRO librerias que takana-install-partition sourcea. Ahora se derivan del propio script — una lista a mano ya habia quedado corta tres veces, y una lib que no viaja no da un error claro: falla al llamarla, ya con el disco en la mano. Certificado: install-tui-test.sh, 6/6 verdes, con dos casos nuevos y un control nuevo — la cuenta completa con cryptpw de verdad (hash SHA-512, nada en claro, permit persist 640, grupos video+input), el usuario cosechado (uid 1047 intacto, sin duplicar, /home sin tocar) y que /etc/profile no lleve el hogar de nadie escrito, porque esto se instala en la maquina de otro. Mas `takana setuid --apply` y `takana service-cards` sobre este mismo arbol, y `takana hash --check` antes y despues: identico. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
10 qillqakuna · +650 −3
- docs/state/targets.toml +22 −0
- recipes/busybox.toml +34 −0
- recipes/mirada-compositor.toml +57 −0
- scripts/instalador-initramfs.sh +11 −1
- scripts/instalador-paquete.sh +11 −1
- scripts/install-tui-test.sh +107 −1
- scripts/iso-image.sh +6 −0
- scripts/lib/cuenta.sh +352 −0
- scripts/takana-install-partition.sh +14 −0
- scripts/takana-live-install.sh +36 −0
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 368a8849..aca1e766 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -409,6 +409,28 @@ paquetes = [
"pata-notify", "pata-notify-panel", "pata-notify-triage",
# el instalador: las dos caras que ya tienen receta, y la de ventana que la espera
"churay-tui", "churay-metal", "churay",
+
+ # ── LO QUE EL ESCRITORIO NECESITA Y NADIE DECLARABA (2026-10-15) ──────────────────────────
+ # Salieron de armar el escritorio del operador a mano y CONTAR lo que quedaba adentro, uno por
+ # uno. Hasta hoy vivian en la variable `EXTRA` de `tawasuyu:scripts/armar-escritorio-de-takana.sh`
+ # ⇒ el escritorio que el operador usa no lo reproducia una instalacion de cero. Es la MISMA fuga
+ # que cerraron las tres `-shared` de arriba: `mirada-usb.sh` cierra las librerias copiando del
+ # devfs de Alpine DEL LAB, asi que el medio arranca y el hueco no se ve hasta que la imagen vive
+ # sola. El precio medido en metal el 14-oct: un escritorio que no pinta un solo rotulo, sin riel
+ # y sin ninguna terminal, en una maquina donde claude ya estaba instalado.
+ "dejavu-fonts", # el compositor NO embute fuentes: sin un .ttf no pinta un solo rotulo, y se calla
+ "fontconfig-shared", # /etc/fonts/fonts.conf y el soname que enlaza el riel
+ "freetype-shared", # libfontconfig.so.1 pide libfreetype.so.6: sin ella el riel muere al cargar
+ "libpng-shared", # y freetype pide libpng16.so.16 — la cadena entera entro por querer letras
+ "alsa-lib", # pata-llimphi pide libasound.so.2: sin ella EL RIEL NO ARRANCA
+ "xkeyboard-config", # la Card exporta XKB_CONFIG_ROOT a /usr/share/X11/xkb, que estaba VACIO
+ "ncurses-shared", # soname de los de arriba y de la TUI
+ "bzip2-shared", # idem
+ "foot", # NINGUNA terminal en una maquina donde ya esta instalado claude
+ "shuma-tui", # el terminal de la suite: corre DENTRO de foot, no lo reemplaza
+ "shuma-daemon", # su cadena
+ "shuma-gateway", # su cadena
+ "shuma-pregunta", # su cadena
]
[perfil.escritorio-kde]
diff --git a/recipes/busybox.toml b/recipes/busybox.toml
index 1cd00007..6c594af3 100644
--- a/recipes/busybox.toml
+++ b/recipes/busybox.toml
@@ -86,3 +86,37 @@ configure = "make CC='zig cc -mcpu=baseline' HOSTCC='zig cc -mcpu=baseline' defc
compile = "make -j\"$(nproc)\" CC='zig cc -mcpu=baseline' HOSTCC='zig cc -mcpu=baseline'"
# busybox instala en CONFIG_PREFIX (no DESTDIR): bin/busybox + symlinks de cada applet.
install = "make CC='zig cc -mcpu=baseline' HOSTCC='zig cc -mcpu=baseline' CONFIG_PREFIX=/out install"
+
+# ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, `[[setuid]]`) ─────────────────────────────────────────────
+# Medido en metal el 2026-10-15, en la máquina del operador: `su` contesta
+#
+# su: must be suid to work properly
+#
+# y era **la única escalada que le quedaba** (root tenía la cuenta cerrada y `sudo`/`doas` no
+# estaban proyectados). El encabezado de `crates/takana-core/src/setuid.rs` lo había previsto
+# textualmente —«hoy es LATENTE: el instalador sólo pone contraseña de root, así que no hay un
+# humano no-root que lo choque»— y ése fue el día que lo hubo.
+#
+# **ONCE applets instalados dependen de este bit** (contados en el rootfs instalado, todos symlink a
+# `bin/busybox`): `su` `login` `passwd` `mount` `umount` `ping` `ping6` `traceroute` `traceroute6`
+# `crontab` `vlock`. Sin él fallan los once, y ninguno nombra el privilegio al fallar: `ping 8.8.8.8`
+# sale 1 al instante y se lee como «no hay red».
+#
+# **Por qué el bit es seguro en ESTE build, y no es una opinión:** el binario trae
+# `CONFIG_FEATURE_SUID` (medido con `strings -a`: la cadena «must be suid to work properly» sólo
+# existe si está compilada, y es la que el operador vio). Con esa opción busybox **suelta el
+# privilegio en `check_suid()`** para todo applet que no lo declara `BB_SUID_REQUIRE` ⇒ `ls`, `sh` y
+# `cat` corren con el uid real aunque el multi-call sea setuid. También trae
+# `CONFIG_FEATURE_SUID_CONFIG` (la cadena `busybox.conf` está en el binario), así que un
+# `/etc/busybox.conf` 0600 root:root puede acotar la lista applet por applet el día que se quiera —
+# si el fichero no existe, manda la tabla compilada, que es este comportamiento.
+#
+# ⚠ Lo que NO se hace acá, y el motivo: Alpine pone el bit en un binario APARTE (`busybox.suid`) y
+# reapunta sólo los symlinks privilegiados, para que el multi-call que corre todo el sistema no sea
+# setuid. Es mejor, y acá cuesta tocar el `install` de esta receta ⇒ **re-hashea busybox y con él las
+# 24 recetas que lo declaran build-dep**. Esta declaración es metadato (fuera de `hash_inputs`) y no
+# mueve un solo `ArtifactHash`, así que entra hoy; el split queda anotado como lo que sigue.
+[[setuid]]
+path = "bin/busybox"
+mode = "4755"
+why = "once applets instalados lo necesitan (su, login, passwd, mount, umount, ping, ping6, traceroute, traceroute6, crontab, vlock) y sin el bit fallan los once sin nombrar el privilegio — `su` dice «must be suid to work properly» y `ping` sale 1 al instante, que se lee como «no hay red». El build trae CONFIG_FEATURE_SUID, así que busybox suelta el privilegio para todo applet que no lo pide."
diff --git a/recipes/mirada-compositor.toml b/recipes/mirada-compositor.toml
index 09dc9b48..07039615 100644
--- a/recipes/mirada-compositor.toml
+++ b/recipes/mirada-compositor.toml
@@ -37,3 +37,60 @@ flags = ["-p", "mirada-compositor"]
[deps]
# libxkbcommon (smithay lo enlaza, no sólo dlopen) + pixman (composición CPU de smithay).
build = ["mesa", "wayland", "wayland-protocols", "libdrm", "seatd", "linux-pam", "libinput", "libudev-zero", "libevdev", "mtdev", "libxkbcommon", "pixman", "expat", "zlib", "zstd", "libffi", "pkgconf"]
+
+# ── LA CARD QUE ARRANCA EL ESCRITORIO (`[[service]]`, 2026-10-15) ─────────────────────────────────
+# Hasta hoy esta receta no declaraba servicio, y la consecuencia estaba MEDIDA y no escrita: la Card
+# que lanza el compositor en la máquina del operador existe porque un script de parche
+# (`tawasuyu:scripts/declarar-mirada-en-la-semilla.sh`) la inyectó en la semilla a mano. O sea que
+# **una instalación de cero arranca el sistema y no arranca el escritorio**, y nada lo avisa: el
+# génesis no se queja de lo que nadie le declaró. Los otros cuatro escritorios ya pasan por
+# `takana service-cards` (lo usan `servidor-image.sh` y los `*-image.sh` de gnome/kde/cosmic);
+# mirada se armaba con un `/sbin/init` escrito a mano por `mirada-usb.sh`, que sirve para un medio
+# live y no deja nada en el disco.
+#
+# La declaración es metadato —fuera de `hash_inputs`, como `[[setuid]]`, `[[user]]` y `license`— así
+# que entra en una receta YA SELLADA sin mover un solo `ArtifactHash`.
+#
+# ⚠ **`seatd` va en la MISMA receta y no en la suya**, por el invariante de `validate_all`: dos
+# recetas no pueden reclamar lo mismo, y acá lo que se declara es el ORDEN de arranque de un
+# escritorio —seatd primero, el compositor después— que es una propiedad de mirada y no de seatd.
+# Sin seatd vivo el compositor no obtiene el seat y muere con el DRM ocupado.
+[[service]]
+label = "seatd"
+id = "01M4ZQ2GXTPZ8RVZRBVZCBE94G"
+exec = "/usr/bin/seatd"
+scope = "system"
+restart = { initial_ms = 500, max_ms = 20000 }
+
+[[service]]
+label = "mirada"
+id = "01M4ZQ2GXTQJ26NFQGXEE1D7BX"
+exec = "/usr/bin/mirada-compositor --drm --greeter"
+scope = "system"
+# El backoff lo hace arje: la versión a mano de esta Card llevaba un `sh -c` de quince líneas que
+# contaba intentos en `/run/mirada-intentos` y se quedaba quieto al tercero. Eso es exactamente lo
+# que `restart` hace, con el tope arriba — un compositor que no abre y reintenta cada 100 ms deja la
+# máquina inservible, y el techo de 30 s es lo que la vuelve diagnosticable.
+restart = { initial_ms = 1000, max_ms = 30000 }
+# ⚠ Un Ente NO hereda el PATH de ningún shell, y el compositor LANZA hijos (el greeter, el riel, la
+# terminal del menú). El PATH de esta Card es el que hereda la sesión entera, y hasta el 2026-10-14
+# decía `/usr/bin:/bin:/usr/sbin:/sbin` — sin `/usr/local/bin`, que es donde vive todo lo instalado
+# a mano. El operador tenía `claude` instalado y la máquina contestaba `claude: not found`.
+#
+# Los bin del hogar NO van acá a propósito: los agrega el propio compositor para el usuario que
+# inicia sesión (`utilidades::path_de_usuario`), y clavarlos en la Card los dejaría apuntando al
+# hogar de UNA persona en una imagen que instala cualquiera.
+envp = [
+ ["PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
+ ["HOME", "/root"],
+ ["XDG_RUNTIME_DIR", "/run/arje"],
+ ["XDG_SEAT", "seat0"],
+ ["XDG_SESSION_TYPE", "wayland"],
+ # El layout por defecto; el instalador pregunta el teclado y lo puede reescribir en la Card.
+ ["XKB_DEFAULT_LAYOUT", "us"],
+ # Sin esto libxkbcommon no encuentra las reglas y el teclado no responde una tecla. Lo aporta
+ # `xkeyboard-config`, declarado en el perfil el mismo día que esto.
+ ["XKB_CONFIG_ROOT", "/usr/share/X11/xkb"],
+ ["MIRADA_GREETER_BIN", "/usr/bin/mirada-greeter"],
+ ["RUST_LOG", "info"],
+]
diff --git a/scripts/instalador-initramfs.sh b/scripts/instalador-initramfs.sh
index 2b16de06..9fed0a30 100755
--- a/scripts/instalador-initramfs.sh
+++ b/scripts/instalador-initramfs.sh
@@ -102,7 +102,17 @@ TAKANA_MUSL="${TAKANA_MUSL:-$ROOT/target/x86_64-unknown-linux-musl/release/takan
[ -x "$TAKANA_MUSL" ] || TAKANA_MUSL="$ROOT/target/release/takana"
cp "$TAKANA_MUSL" "$IRD_TREE/bin/takana"; chmod 0755 "$IRD_TREE/bin/takana"
cp scripts/takana-install-partition.sh "$IRD_TREE/instalador/"
-cp scripts/lib/herramientas-desde-store.sh "$IRD_TREE/instalador/lib/"
+# ── LAS LIBRERÍAS QUE EL INSTALADOR SOURCEA, DERIVADAS DEL PROPIO SCRIPT ──────────────────────────
+# Acá había UNA copia a mano (`herramientas-desde-store.sh`) y el script sourcea CUATRO:
+# `usuarios-del-home.sh`, `adoptar.sh`, `cuenta.sh` y ésa. Una librería que no viaja no da un error
+# claro: el `.` falla y el instalador sigue hasta que llama a la función, ya con el disco en la mano.
+# Derivarlas del script es lo que hace que agregar la quinta no exija acordarse de este sitio — la
+# lista a mano ya había quedado corta tres veces.
+for _lib in $(sed -n 's|^\. "$(dirname "$0")/lib/\([a-z-]*\.sh\)".*|\1|p' scripts/takana-install-partition.sh); do
+ [ -r "scripts/lib/$_lib" ] || { echo "falta scripts/lib/$_lib, que el instalador sourcea" >&2; exit 1; }
+ cp "scripts/lib/$_lib" "$IRD_TREE/instalador/lib/"
+done
+echo " libs del instalador: $(sed -n 's|^\. "$(dirname "$0")/lib/\([a-z-]*\.sh\)".*|\1|p' scripts/takana-install-partition.sh | tr '\n' ' ')"
cp "$KERNEL" "$IRD_TREE/payload/bzImage"
mkdir -p "$IRD_TREE/payload/stage"
cp -a "$STAGE/root" "$IRD_TREE/payload/stage/root"
diff --git a/scripts/instalador-paquete.sh b/scripts/instalador-paquete.sh
index 5864dcfe..6a3c36d5 100755
--- a/scripts/instalador-paquete.sh
+++ b/scripts/instalador-paquete.sh
@@ -57,7 +57,17 @@ poner() { rm -f "$2"; cp "$1" "$2"; chmod "${3:-0644}" "$2"; }
poner "$TAKANA" "$SALIDA/takana" 0755
poner "$KERNEL" "$SALIDA/kernel/boot/bzImage"
poner scripts/takana-install-partition.sh "$SALIDA/takana-install-partition.sh" 0755
-poner scripts/lib/herramientas-desde-store.sh "$SALIDA/lib/herramientas-desde-store.sh"
+# ── LAS LIBRERÍAS QUE EL INSTALADOR SOURCEA, DERIVADAS DEL PROPIO SCRIPT ──────────────────────────
+# Acá había UNA copia a mano (`herramientas-desde-store.sh`) y el script sourcea CUATRO:
+# `usuarios-del-home.sh`, `adoptar.sh`, `cuenta.sh` y ésa. Una librería que no viaja no da un error
+# claro: el `.` falla y el instalador sigue hasta que llama a la función, ya con el disco en la mano.
+# Derivarlas del script es lo que hace que agregar la quinta no exija acordarse de este sitio — la
+# lista a mano ya había quedado corta tres veces.
+for _lib in $(sed -n 's|^\. "$(dirname "$0")/lib/\([a-z-]*\.sh\)".*|\1|p' scripts/takana-install-partition.sh); do
+ [ -r "scripts/lib/$_lib" ] || { echo "falta scripts/lib/$_lib, que el instalador sourcea" >&2; exit 1; }
+ poner "scripts/lib/$_lib" "$SALIDA/lib/$_lib"
+done
+echo " libs del instalador: $(sed -n 's|^\. "$(dirname "$0")/lib/\([a-z-]*\.sh\)".*|\1|p' scripts/takana-install-partition.sh | tr '\n' ' ')"
# `busybox` viaja suelto en el paquete —2 MiB— y no es redundante: es con lo que se desempaqueta el
# árbol a instalar cuando NO hace falta saltar. El host puede no tener `cpio` ni `gzip` (en las tres
# máquinas de este proyecto no hay `cpio`), así que depender de los del sistema ajeno es depender de
diff --git a/scripts/install-tui-test.sh b/scripts/install-tui-test.sh
index 88eeaa40..85b103fc 100755
--- a/scripts/install-tui-test.sh
+++ b/scripts/install-tui-test.sh
@@ -31,7 +31,11 @@ printf '127.0.0.1\tlocalhost\n' > "$R1/etc/hosts"
echo "== test 1: modo INTERACTIVO (TUI dry-run) =="
# Respuestas: disco #2 (vdb) · root 2048M · store 512M · hostname midistro · cred 2 (ssh) · authkeys ·
-# zona America/La_Paz · teclado (default us) · confirmación "/dev/vdb"
+# USUARIO ana · zona America/La_Paz · teclado (default us) · confirmación "/dev/vdb"
+#
+# ⚠ `ana` es la respuesta de la SECCIÓN DE CUENTA (lib/cuenta.sh, 2026-10-15). Este rootfs de
+# scratch no trae `usr/bin/doas`, así que la librería NO pregunta por administración —y eso es
+# determinista a propósito: mira el rootfs del test y no el de la máquina que corre el test—.
OUT1="$W/out1.txt"
BB=/nonexistent SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$ZI" \
TAKANA_INSTALL_INTERACTIVE=1 TAKANA_INSTALL_DRYRUN=1 TAKANA_INSTALL_TESTROOT="$R1" \
@@ -42,6 +46,7 @@ BB=/nonexistent SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$ZI" \
midistro
2
ssh-ed25519 AAAATESTKEY user@host
+ana
America/La_Paz
/dev/vdb
@@ -60,7 +65,29 @@ grep -q 'HOSTNAME=midistro' "$R1/etc/hammer/install.conf" || fail "install.conf
grep -q 'KEYMAP=us' "$R1/etc/hammer/install.conf" || fail "install.conf sin keymap default"
# root en shadow debe seguir intacto (interactivo eligió ssh, no tocó la contraseña)
grep -q '^root:!:' "$R1/etc/shadow" || fail "shadow de root alterado sin querer"
+# ── la cuenta (lib/cuenta.sh) ──────────────────────────────────────────────────────────────────
+# Sin `cryptpw` (BB=/nonexistent) no se puede hashear nada, así que lo que se exige acá es que la
+# cuenta EXISTA con su hogar y que el instalador DIGA que queda cerrada. Un instalador que la deja
+# cerrada en silencio es el defecto que esta sección cierra.
+grep -q "^ana:x:1000:1000:" "$R1/etc/passwd" || { cat "$R1/etc/passwd"; fail "el usuario ana no quedó en passwd con uid 1000"; }
+grep -q "^ana:x:1000:" "$R1/etc/group" || { cat "$R1/etc/group"; fail "el grupo ana no quedó en group"; }
+[ -d "$R1/home/ana" ] || fail "/home/ana no se creó"
+grep -q '^ana:!:' "$R1/etc/shadow" || { cat "$R1/etc/shadow"; fail "ana debía quedar con la cuenta cerrada (no hay cryptpw)"; }
+grep -q 'SIN contraseña posible' "$OUT1" || { cat "$OUT1"; fail "no avisó que la cuenta queda sin contraseña"; }
+grep -q 'INADMINISTRABLE' "$OUT1" || { cat "$OUT1"; fail "no avisó que la máquina queda inadministrable (root cerrado y nadie escala)"; }
+[ ! -e "$R1/etc/doas.conf" ] || fail "escribió doas.conf en una imagen que no trae doas"
echo " ✓ interactivo: disco elegido + confirmado, config escrita (hostname/hosts/ssh/tz/keymap)"
+# ── /etc/profile: que exista, y que NO sea de NADIE EN PARTICULAR ─────────────────────────────
+# El segundo control es el que importa para una imagen que instala un desconocido: la primera
+# versión de este PATH llevaba `/home/sergio/.local/bin` escrito a mano, porque nació de arreglar
+# UNA máquina. Un fichero de `/etc` con el hogar de una persona adentro es un defecto que no se ve
+# hasta que lo instala otra.
+[ -f "$R1/etc/profile" ] || fail "/etc/profile no se escribió (sin él nadie lee /etc/profile.d y /usr/local/bin no existe para el shell)"
+grep -q '/usr/local/bin' "$R1/etc/profile" || fail "/etc/profile no garantiza /usr/local/bin"
+grep -q '/etc/profile.d' "$R1/etc/profile" || fail "/etc/profile no sourcea profile.d"
+grep -q '/home/[a-z]' "$R1/etc/profile" && { grep -n '/home/[a-z]' "$R1/etc/profile"; fail "/etc/profile lleva el hogar de UNA persona: no sirve para instalar en otra máquina"; }
+echo " ✓ la cuenta: usuario creado con uid 1000, su hogar, y los DOS avisos (cerrada / inadministrable)"
+echo " ✓ /etc/profile: los seis directorios, profile.d sourceado, y SIN el hogar de nadie escrito"
echo "== test 2: confirmación ERRÓNEA cancela sin tocar nada =="
OUT2="$W/out2.txt"
@@ -72,6 +99,7 @@ if BB=/nonexistent SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$ZI" \
512
h
3
+
us
sda-equivocado
ANSWERS
@@ -97,5 +125,83 @@ echo "== test 4: sin device y sin TTY ⇒ error de uso (no cuelga) =="
if BB=/nonexistent sh "$INST" </dev/null >/dev/null 2>&1; then fail "debió fallar sin device ni TTY"; fi
echo " ✓ uso inválido reportado"
+echo "== test 5: LA CUENTA COMPLETA, con cryptpw de verdad =="
+# Lo que los tests 1-4 no pueden mostrar: que la contraseña se hashea, que la regla de doas se
+# escribe, y que el usuario entra a los grupos del hardware. Necesita un busybox con `cryptpw`; si
+# no hay, se SALTA diciéndolo — un test que se calla cuando no puede medir es peor que ninguno.
+BBREAL=$(command -v busybox 2>/dev/null || true)
+if [ -n "$BBREAL" ] && printf x | "$BBREAL" cryptpw -m sha512 >/dev/null 2>&1; then
+ R5="$W/root5"; mkdir -p "$R5/etc" "$R5/usr/bin" "$R5/usr/share/zoneinfo"
+ printf 'root:!:19000:0:99999:7:::\n' > "$R5/etc/shadow"
+ printf 'root:x:0:0:root:/root:/bin/sh\n' > "$R5/etc/passwd"
+ printf 'root:x:0:\nvideo:x:27:\ninput:x:97:\n' > "$R5/etc/group"
+ printf '127.0.0.1\tlocalhost\n' > "$R5/etc/hosts"
+ : > "$R5/usr/bin/doas"; chmod 755 "$R5/usr/bin/doas" # con doas, la librería SÍ pregunta por admin
+ OUT5="$W/out5.txt"
+ # Respuestas: disco 2 · 2048 · 512 · hostname · cred 3 (root sin cambios) · usuario ana ·
+ # contraseña ×2 · admin 1 · zona (vacío) · teclado (default) · confirmación
+ BB="$BBREAL" SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$R5/usr/share/zoneinfo" \
+ TAKANA_INSTALL_INTERACTIVE=1 TAKANA_INSTALL_DRYRUN=1 TAKANA_INSTALL_TESTROOT="$R5" \
+ sh "$INST" >"$OUT5" 2>&1 <<'ANSWERS' || { cat "$OUT5"; fail "test 5 salió con error"; }
+2
+2048
+512
+midistro
+3
+ana
+clave-de-prueba
+clave-de-prueba
+1
+
+
+/dev/vdb
+ANSWERS
+ grep -qE '^ana:\$6\$' "$R5/etc/shadow" || { cat "$R5/etc/shadow"; fail "la contraseña de ana no quedó hasheada en SHA-512"; }
+ grep -q 'clave-de-prueba' "$R5/etc/shadow" "$OUT5" && fail "LA CONTRASEÑA EN CLARO quedó escrita en algún sitio"
+ grep -q '^permit persist ana$' "$R5/etc/doas.conf" || { cat "$R5/etc/doas.conf" 2>/dev/null; fail "la regla de doas no se escribió"; }
+ [ "$(stat -c%a "$R5/etc/doas.conf")" = 640 ] || fail "doas.conf no quedó 640"
+ grep -q '^video:x:27:ana$' "$R5/etc/group" || { grep '^video' "$R5/etc/group"; fail "ana no entró al grupo video (seatd lo exige por nombre)"; }
+ grep -q '^input:x:97:ana$' "$R5/etc/group" || fail "ana no entró al grupo input"
+ grep -q 'INADMINISTRABLE' "$OUT5" && fail "avisó inadministrable cuando ana SÍ puede escalar"
+ echo " ✓ cuenta completa: hash SHA-512, nada en claro, permit persist 640, grupos video+input"
+
+ echo "== test 6: el usuario YA EXISTE (cosechado del /home conservado) ⇒ sólo su contraseña =="
+ # Éste es el caso que vivió el operador: `--keep` cosecha `sergio` del /home con el uid de sus
+ # archivos y `!` por contraseña. La sección NO debe crear un segundo usuario ni cambiarle el uid:
+ # si le cambia el uid, sus archivos dejan de ser suyos — el único daño irreparable que hay acá.
+ R6="$W/root6"; mkdir -p "$R6/etc" "$R6/usr/bin" "$R6/home/ana" "$R6/usr/share/zoneinfo"
+ printf 'root:x:0:0:root:/root:/bin/sh\nana:x:1047:1047:ana:/home/ana:/bin/sh\n' > "$R6/etc/passwd"
+ printf 'root:x:0:\nana:x:1047:\n' > "$R6/etc/group"
+ printf 'root:!:19000:0:99999:7:::\nana:!:20000:0:99999:7:::\n' > "$R6/etc/shadow"
+ printf '127.0.0.1\tlocalhost\n' > "$R6/etc/hosts"
+ : > "$R6/usr/bin/doas"; chmod 755 "$R6/usr/bin/doas"
+ : > "$R6/home/ana/lo-que-ya-tenia"
+ OUT6="$W/out6.txt"
+ BB="$BBREAL" SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$R6/usr/share/zoneinfo" \
+ TAKANA_INSTALL_INTERACTIVE=1 TAKANA_INSTALL_DRYRUN=1 TAKANA_INSTALL_TESTROOT="$R6" \
+ sh "$INST" >"$OUT6" 2>&1 <<'ANSWERS' || { cat "$OUT6"; fail "test 6 salió con error"; }
+2
+2048
+512
+midistro
+3
+ana
+otra-clave
+otra-clave
+1
+
+
+/dev/vdb
+ANSWERS
+ [ "$(grep -c '^ana:' "$R6/etc/passwd")" = 1 ] || { grep '^ana:' "$R6/etc/passwd"; fail "duplicó el usuario ana"; }
+ grep -q '^ana:x:1047:1047:' "$R6/etc/passwd" || { grep '^ana:' "$R6/etc/passwd"; fail "le CAMBIÓ el uid: sus archivos dejarían de ser suyos"; }
+ grep -qE '^ana:\$6\$' "$R6/etc/shadow" || fail "no le puso contraseña al usuario cosechado"
+ [ -f "$R6/home/ana/lo-que-ya-tenia" ] || fail "tocó el /home que ya existía"
+ grep -q 'ya existe con uid 1047' "$OUT6" || { cat "$OUT6"; fail "no dijo que reusaba el usuario cosechado"; }
+ echo " ✓ usuario cosechado: uid 1047 intacto, sin duplicar, /home sin tocar, contraseña puesta"
+else
+ echo " ⊘ SALTADO: no hay un busybox con cryptpw en esta máquina (los tests 5 y 6 no se midieron)"
+fi
+
echo
echo "✓✓ install-tui-test: TODOS los casos verdes"
diff --git a/scripts/iso-image.sh b/scripts/iso-image.sh
index 0ebd701a..9fee54a5 100755
--- a/scripts/iso-image.sh
+++ b/scripts/iso-image.sh
@@ -125,6 +125,12 @@ if [ "${INSTALLER:-0}" = 1 ]; then
grub-mkimage -O i386-pc -p '(hd0,msdos1)/boot/grub' -o "$IPL/grub/core.img" \
biosdisk part_msdos ext2 normal configfile linux echo ls boot search
install -m 0755 "$ROOT/scripts/takana-live-install.sh" "$RFS/root/usr/bin/takana-install"
+ # La sección de la cuenta viaja CON el instalador: `scripts/lib/` no existe dentro del producto,
+ # y sin la librería el instalador no pregunta por ningún usuario — que es el defecto que esa
+ # librería existe para cerrar (medido en metal el 2026-10-14: máquina instalada e
+ # inadministrable). El instalador la busca acá y avisa en el resumen si no la encuentra.
+ install -d -m 0755 "$RFS/root/usr/lib/takana"
+ install -m 0644 "$ROOT/scripts/lib/cuenta.sh" "$RFS/root/usr/lib/takana/cuenta.sh"
# hammer-recover (static musl): auto-sana un upgrade interrumpido al arranque del sistema instalado.
RECOVER_BIN="$ROOT/target/x86_64-unknown-linux-musl/release/hammer-recover"
if [ ! -x "$RECOVER_BIN" ]; then
diff --git a/scripts/lib/cuenta.sh b/scripts/lib/cuenta.sh
new file mode 100644
index 00000000..29be5e0c
--- /dev/null
+++ b/scripts/lib/cuenta.sh
@@ -0,0 +1,352 @@
+#!/bin/sh
+# lib/cuenta.sh — LA CUENTA DE QUIEN VA A USAR LA MÁQUINA: se pregunta antes de instalar y se
+# escribe al terminar. Es la sección que faltaba en los dos instaladores.
+#
+# ── El defecto, medido en metal el 2026-10-14/15 ─────────────────────────────────────────────────
+# El operador instaló, arrancó, y **no podía hacer nada**: ni entrar como root, ni escalar. Lo dijo
+# así: *«al correr su me dice que debe ser suid, así que se cagó ese boot»* y *«¿de dónde salió el
+# usuario sergio? me di cuenta que debe ser una sección antes de instalar»*.
+#
+# Las dos preguntas tienen la misma respuesta, y es ésta: **nadie preguntó nunca por una cuenta.**
+#
+# · `takana-live-install.sh` (disco entero) pregunta hostname, zona, teclado y la credencial de
+# ROOT — y ningún usuario. En una imagen de escritorio eso es una máquina donde el greeter no
+# puede autenticar a nadie, porque no hay nadie.
+# · `takana-install-partition.sh` (el camino de `--keep`/`--adopt`, el que usó él) **no pregunta
+# NADA**. Los usuarios salen de `lib/usuarios-del-home.sh`, que los cosecha del `/home` que se
+# conserva —un usuario por directorio, con el uid de sus archivos— y les pone `!` en
+# `/etc/shadow`, que es «cuenta sin contraseña posible». Correcto de su parte: inventar una
+# contraseña sería peor. Pero nadie, después, la pedía.
+#
+# Resultado medido: `sergio:!` + `root:!` + ningún `doas.conf` = una máquina instalada, arrancada y
+# **inadministrable**. El encabezado de `takana-core/src/setuid.rs` lo había previsto textualmente
+# («hoy es LATENTE: el instalador sólo pone contraseña de root, así que no hay un humano no-root que
+# lo choque») y el 14 de octubre lo hubo.
+#
+# ── Por qué una librería y no dos secciones ──────────────────────────────────────────────────────
+# El mismo argumento que `lib/usuarios-del-home.sh` escribe para sí: son DOS caminos y UNA pregunta,
+# y dos implementaciones divergen en el caso raro. Acá el caso raro es justo el del operador —el
+# usuario YA existe, cosechado del `/home` conservado, y lo único que falta es su contraseña— y una
+# de las dos copias se lo habría perdido.
+#
+# ── La regla de root, sin criterio ───────────────────────────────────────────────────────────────
+# Root queda **cerrado** si el usuario puede escalar (hay `doas` en la imagen y se pidió que
+# administre): es lo correcto, y es lo que hacen las distros desde hace una década. Si NO puede
+# escalar, entonces se pide contraseña de root **obligatoriamente** — porque una máquina sin
+# ninguna de las dos no se administra, y eso es exactamente lo que acabamos de medir.
+#
+# Se usa así (los dos instaladores):
+#
+# . "$(dirname "$0")/lib/cuenta.sh"
+# cuenta_preguntar "$RAIZ_DEL_MEDIO" # TUI; no pregunta si no hay tty o si es desatendido
+# ...
+# cuenta_aplicar "$ARBOL" # escribe passwd/group/shadow/home/doas.conf
+#
+# Desatendido (arneses y `--unattended`): `TAKANA_USER`, `TAKANA_USER_HASH` (un hash, NO una
+# contraseña), `TAKANA_USER_PW` (sólo donde no haya más remedio), `TAKANA_ADMIN=1|0`,
+# `TAKANA_ROOT_PW` / `TAKANA_ROOT_HASH` como ya existían.
+#
+# ⚠ **La contraseña NO viaja por la cmdline del kernel.** `/proc/cmdline` lo lee cualquiera, así que
+# un hash ahí es un ataque de diccionario offline servido en bandeja. Por eso esto pregunta en el
+# instalador —que tiene tty— en vez de recibirlo del que hizo el kexec.
+
+CUENTA_USER="${TAKANA_USER:-}"
+CUENTA_HASH="${TAKANA_USER_HASH:-}"
+CUENTA_ADMIN="${TAKANA_ADMIN:-1}"
+CUENTA_ROOT_HASH="${TAKANA_ROOT_HASH:-${HAMMER_ROOT_HASH:-}}"
+
+# Los dos instaladores ya traen sus propios `ask`/`read_secret`/`bb`; si falta alguno (al usar la
+# librería desde un arnés) se definen acá. Un `command -v` sobre una función de shell la encuentra.
+command -v bb >/dev/null 2>&1 || bb() { busybox "$@" 2>/dev/null || command "$@"; }
+command -v ask >/dev/null 2>&1 || ask() {
+ _a_v="$1"; printf '%s%s: ' "$2" "$([ -n "${3:-}" ] && printf ' [%s]' "$3")"
+ IFS= read -r _a_r || _a_r=""
+ [ -n "$_a_r" ] || _a_r="${3:-}"
+ eval "$_a_v=\$_a_r"
+}
+command -v read_secret >/dev/null 2>&1 || read_secret() {
+ printf '%s: ' "$2"; stty -echo 2>/dev/null || true
+ IFS= read -r _rs || _rs=""; stty echo 2>/dev/null || true; echo
+ eval "$1=\$_rs"
+}
+
+# cuenta_hash <contraseña> — SHA-512 por `cryptpw`. Devuelve vacío si este busybox no lo trae, y el
+# llamador lo dice: un hash vacío escribiría una cuenta SIN CONTRASEÑA, que es peor que no tenerla.
+cuenta_hash() {
+ printf '%s' "$1" | bb cryptpw -m sha512 2>/dev/null || true
+}
+
+# cuenta_login_valido <nombre> — el mismo criterio que `lib/usuarios-del-home.sh` usa al cosechar,
+# porque los dos juegos de usuarios tienen que poder convivir en el mismo `/etc/passwd`.
+cuenta_login_valido() {
+ case "$1" in
+ "" | -* | *[!a-z0-9_-]*) return 1 ;;
+ root | sshd | bin | daemon | nobody | messagebus) return 1 ;;
+ *) [ "${#1}" -le 32 ] ;;
+ esac
+}
+
+# cuenta_preguntar [raíz-del-medio] — la sección. No pregunta si no hay tty, si es desatendido, o si
+# ya vino todo por el ambiente.
+cuenta_preguntar() {
+ _cp_raiz="${1:-/}"
+ [ "${TAKANA_UNATTENDED:-0}" = 1 ] && return 0
+ # ⚠ La condición es la MISMA que usa el instalador para decidir si es interactivo, y no sólo
+ # `[ -t 0 ]`: con stdin alimentado por un heredoc —que es como se prueba una TUI sin una persona
+ # delante— no hay tty, y la sección se saltaba entera dejando el arnés en verde sobre un
+ # instalador que no preguntaba nada. Medido escribiéndolo.
+ [ -t 0 ] || [ "${TAKANA_INSTALL_INTERACTIVE:-${HAMMER_INSTALL_INTERACTIVE:-}}" = 1 ] || return 0
+ [ -n "$CUENTA_USER" ] && [ -n "$CUENTA_HASH" ] && return 0
+
+ # ¿Hay con qué escalar en la imagen que se está instalando? La respuesta cambia la regla de root,
+ # así que se MIRA y no se supone.
+ _cp_doas=no
+ for _d in usr/bin/doas bin/doas usr/local/bin/doas usr/bin/sudo; do
+ [ -e "$_cp_raiz/$_d" ] && { _cp_doas=sí; break; }
+ done
+
+ echo
+ echo "Tu cuenta en esta máquina"
+ echo " ─────────────────────────"
+ echo " Es la que vas a usar para entrar al escritorio. Sin una cuenta con contraseña,"
+ echo " el escritorio arranca y no deja entrar a nadie."
+ while :; do
+ ask CUENTA_USER "Nombre de usuario" "${CUENTA_USER:-}"
+ [ -z "$CUENTA_USER" ] && { echo " (sin usuario: la máquina queda sólo con root)"; break; }
+ cuenta_login_valido "$CUENTA_USER" && break
+ echo " ✗ «$CUENTA_USER» no sirve de login: minúsculas, dígitos, «_» y «-», sin empezar con «-»,"
+ echo " y no puede ser un usuario de sistema (root, sshd, nobody…)."
+ CUENTA_USER=""
+ done
+
+ if [ -n "$CUENTA_USER" ]; then
+ if [ -z "$CUENTA_HASH" ]; then
+ if [ -n "$(cuenta_hash x)" ]; then
+ while :; do
+ read_secret _cp_p1 "Contraseña para $CUENTA_USER"
+ read_secret _cp_p2 "Repetir"
+ [ "$_cp_p1" = "$_cp_p2" ] && [ -n "$_cp_p1" ] || { echo " no coinciden (o vacía) — reintentá"; continue; }
+ CUENTA_HASH=$(cuenta_hash "$_cp_p1")
+ [ -n "$CUENTA_HASH" ] || { echo " ⚠ no pude generar el hash"; break; }
+ break
+ done
+ _cp_p1=; _cp_p2=
+ else
+ echo " ⚠ este busybox no trae cryptpw: no puedo poner contraseña y la cuenta queda cerrada."
+ fi
+ fi
+ if [ "$_cp_doas" = sí ]; then
+ ask CUENTA_ADMIN "¿$CUENTA_USER administra la máquina (doas)? 1=sí 0=no" "1"
+ else
+ echo " ⚠ esta imagen no trae doas ni sudo: nadie puede escalar, así que root necesita contraseña."
+ CUENTA_ADMIN=0
+ fi
+ fi
+
+ # La regla de root, aplicada: cerrada si hay quien escale, obligatoria si no.
+ if [ -n "$CUENTA_USER" ] && [ "$CUENTA_ADMIN" = 1 ] && [ -n "$CUENTA_HASH" ]; then
+ [ -n "$CUENTA_ROOT_HASH" ] || echo " · root queda CERRADO: se administra con «doas» desde $CUENTA_USER."
+ elif [ -z "$CUENTA_ROOT_HASH" ]; then
+ echo
+ echo " Nadie puede escalar todavía ⇒ root necesita contraseña, o la máquina queda inadministrable."
+ if [ -n "$(cuenta_hash x)" ]; then
+ while :; do
+ read_secret _cp_r1 "Contraseña de root"
+ read_secret _cp_r2 "Repetir"
+ [ "$_cp_r1" = "$_cp_r2" ] && [ -n "$_cp_r1" ] || { echo " no coinciden (o vacía) — reintentá"; continue; }
+ CUENTA_ROOT_HASH=$(cuenta_hash "$_cp_r1"); break
+ done
+ _cp_r1=; _cp_r2=
+ fi
+ fi
+}
+
+# cuenta_resumen — una línea para el resumen de confirmación del instalador.
+cuenta_resumen() {
+ if [ -z "$CUENTA_USER" ]; then printf 'sin usuario (sólo root)'; return; fi
+ printf '%s' "$CUENTA_USER"
+ [ -n "$CUENTA_HASH" ] && printf ' con contraseña' || printf ' SIN contraseña (cuenta cerrada)'
+ [ "$CUENTA_ADMIN" = 1 ] && printf ', administra (doas)'
+}
+
+# _cuenta_romper_hardlink <fichero> — el árbol del stage son HARDLINKS del rootfs sellado (ADR
+# 0005): un `>>` sobre `etc/passwd` escribiría DENTRO del artefacto del store y lo envenenaría para
+# todas las imágenes, sin que nada falle. Es la misma precaución que toma `usuarios_aplicar`, y la
+# razón de que acá no haya un solo `>>` directo.
+_cuenta_romper_hardlink() {
+ [ -e "$1" ] || return 0
+ cp "$1" "$1.cuenta-tmp" && mv "$1.cuenta-tmp" "$1"
+}
+
+# cuenta_aplicar <árbol> — escribe la cuenta en la raíz nueva. Idempotente: si el usuario ya existe
+# (cosechado del `/home` conservado) NO lo vuelve a crear y sólo le pone su contraseña, que es
+# exactamente el caso del operador.
+cuenta_aplicar() {
+ _ca_t="$1"
+ bb mkdir -p "$_ca_t/etc"
+ for _f in etc/passwd etc/group etc/shadow; do _cuenta_romper_hardlink "$_ca_t/$_f"; done
+ [ -e "$_ca_t/etc/passwd" ] || : > "$_ca_t/etc/passwd"
+ [ -e "$_ca_t/etc/group" ] || : > "$_ca_t/etc/group"
+ [ -e "$_ca_t/etc/shadow" ] || { : > "$_ca_t/etc/shadow"; bb chmod 600 "$_ca_t/etc/shadow"; }
+
+ if [ -n "$CUENTA_USER" ]; then
+ if bb awk -F: -v n="$CUENTA_USER" '$1==n{f=1} END{exit !f}' "$_ca_t/etc/passwd"; then
+ _ca_uid=$(bb awk -F: -v n="$CUENTA_USER" '$1==n{print $3; exit}' "$_ca_t/etc/passwd")
+ echo " · «$CUENTA_USER» ya existe con uid $_ca_uid (del /home conservado): le pongo su contraseña"
+ else
+ # El primer uid libre desde 1000. Se cuenta sobre el passwd que YA tiene los usuarios
+ # cosechados, así que no puede chocar con ellos.
+ _ca_uid=1000
+ while bb awk -F: -v u="$_ca_uid" '$3==u{f=1} END{exit !f}' "$_ca_t/etc/passwd"; do
+ _ca_uid=$((_ca_uid + 1))
+ done
+ printf '%s:x:%s:%s:%s:/home/%s:/bin/sh\n' \
+ "$CUENTA_USER" "$_ca_uid" "$_ca_uid" "$CUENTA_USER" "$CUENTA_USER" >> "$_ca_t/etc/passwd"
+ bb awk -F: -v g="$_ca_uid" '$3==g{f=1} END{exit !f}' "$_ca_t/etc/group" \
+ || printf '%s:x:%s:\n' "$CUENTA_USER" "$_ca_uid" >> "$_ca_t/etc/group"
+ echo " · usuario «$CUENTA_USER» creado con uid $_ca_uid"
+ fi
+ # El hogar: sólo si NO existe. Con `--keep` o `--adopt` ya está ahí con sus archivos y sus
+ # dueños, y pisarlo sería el único daño irreparable que puede hacer un instalador.
+ if [ ! -e "$_ca_t/home/$CUENTA_USER" ]; then
+ bb mkdir -p "$_ca_t/home/$CUENTA_USER"
+ bb chown "$_ca_uid:$_ca_uid" "$_ca_t/home/$CUENTA_USER" 2>/dev/null || true
+ bb chmod 700 "$_ca_t/home/$CUENTA_USER"
+ echo " · /home/$CUENTA_USER creado (uid $_ca_uid)"
+ else
+ echo " · /home/$CUENTA_USER ya existe: NO lo toco"
+ fi
+ # Los grupos del hardware: seatd exige `video` POR NOMBRE, y sin `input` libinput no abre los
+ # dispositivos. Sólo si el grupo existe en la imagen — inventar un gid acá sería adivinar.
+ for _g in video input audio seat; do
+ bb awk -F: -v g="$_g" -v u="$CUENTA_USER" '
+ BEGIN{OFS=":"}
+ $1==g { if ($4=="") $4=u; else if ($4 !~ ("(^|,)" u "(,|$)")) $4=$4","u; f=1 }
+ {print}
+ END{exit !f}' "$_ca_t/etc/group" > "$_ca_t/etc/group.cuenta-tmp" \
+ && mv "$_ca_t/etc/group.cuenta-tmp" "$_ca_t/etc/group" \
+ || rm -f "$_ca_t/etc/group.cuenta-tmp"
+ done
+ if [ -n "$CUENTA_HASH" ]; then
+ _cuenta_set_shadow "$CUENTA_USER" "$CUENTA_HASH" "$_ca_t"
+ else
+ _cuenta_set_shadow "$CUENTA_USER" '!' "$_ca_t"
+ echo " ⚠ «$CUENTA_USER» queda SIN contraseña posible: nadie va a poder entrar con esa cuenta"
+ fi
+ # La regla de escalada. Sin esto doas RECHAZA TODO («no rule matched») aunque esté instalado y
+ # con su bit: quién escala no lo puede decidir el paquete, lo decide esta máquina.
+ if [ "$CUENTA_ADMIN" = 1 ]; then
+ if [ -e "$_ca_t/usr/bin/doas" ] || [ -e "$_ca_t/bin/doas" ]; then
+ if [ -e "$_ca_t/etc/doas.conf" ]; then
+ echo " · /etc/doas.conf ya existe: no lo toco"
+ else
+ printf '# Quién puede ser root en esta máquina. La ÚLTIMA regla que casa es la que vale.\npermit persist %s\n' \
+ "$CUENTA_USER" > "$_ca_t/etc/doas.conf"
+ bb chown 0:0 "$_ca_t/etc/doas.conf" 2>/dev/null || true
+ bb chmod 640 "$_ca_t/etc/doas.conf"
+ echo " · /etc/doas.conf: permit persist $CUENTA_USER"
+ fi
+ else
+ echo " ⚠ se pidió que «$CUENTA_USER» administre y esta imagen NO trae doas: no hay escalada"
+ fi
+ fi
+ fi
+
+ if [ -n "$CUENTA_ROOT_HASH" ]; then
+ _cuenta_set_shadow root "$CUENTA_ROOT_HASH" "$_ca_t"
+ echo " · root con contraseña"
+ fi
+
+ # El entorno de login de esa cuenta. Va acá y no en otra sección porque una cuenta cuyo shell no
+ # encuentra `/usr/local/bin` es media cuenta: es lo que dejó al operador con `claude` instalado y
+ # `claude: not found` en la consola.
+ cuenta_perfil_de_login "$_ca_t"
+
+ # ── El brazo de control, ACÁ y no en un test aparte ───────────────────────────────────────────
+ # Una cuenta mal escrita no se nota hasta el primer arranque, y arreglarla cuesta montar el disco
+ # desde otro sistema. Se comprueba lo que se acaba de escribir, y se dice en voz alta.
+ _ca_mal=0
+ if [ -n "$CUENTA_USER" ]; then
+ bb awk -F: -v n="$CUENTA_USER" '$1==n{f=1} END{exit !f}' "$_ca_t/etc/passwd" \
+ || { echo " ✗ «$CUENTA_USER» NO quedó en /etc/passwd"; _ca_mal=$((_ca_mal+1)); }
+ if [ -n "$CUENTA_HASH" ]; then
+ bb awk -F: -v n="$CUENTA_USER" '$1==n && $2 ~ /^\$/{f=1} END{exit !f}' "$_ca_t/etc/shadow" \
+ || { echo " ✗ «$CUENTA_USER» no quedó con un hash en /etc/shadow"; _ca_mal=$((_ca_mal+1)); }
+ fi
+ fi
+ # El aviso cubre las TRES formas de quedar sin nadie que administre: sin contraseña de root y
+ # (sin usuario | con usuario que no escala | con usuario sin contraseña). La primera versión de
+ # esta línea sólo miraba `ADMIN != 1`, y con el default `ADMIN=1` y usuario VACÍO se callaba —
+ # que es exactamente la combinación con la que arrancó la máquina del operador.
+ if [ -z "$CUENTA_ROOT_HASH" ] \
+ && { [ -z "$CUENTA_USER" ] || [ "$CUENTA_ADMIN" != 1 ] || [ -z "$CUENTA_HASH" ]; }; then
+ echo " ⚠ root cerrado y nadie escala: esta máquina va a arrancar INADMINISTRABLE"
+ fi
+ [ "$_ca_mal" = 0 ]
+}
+
+# cuenta_perfil_de_login <árbol> — `/etc/profile`, que es lo que lee el shell de esa cuenta.
+#
+# ── Por qué está acá y no en una receta ──────────────────────────────────────────────────────────
+# Medido en la máquina del operador: **`/etc/profile` NO EXISTÍA**. Y como el `ash` de busybox
+# sourcea `/etc/profile.d/*` sólo si ese archivo lo hace, `/etc/profile.d/tawasuyu-dev.sh` —que
+# exportaba el PATH del toolchain— estuvo inerte desde el primer arranque: `claude` instalado en
+# `/usr/local/bin` y la consola contestando `claude: not found`. Las recetas no tienen mecanismo
+# para aportar un fichero de `/etc` (no hay `[[file]]`: lo que entra sale del `install` de la
+# receta, y tocar eso re-hashea), así que el sitio donde nace un `/etc` de ESTA máquina es el
+# instalador — el mismo que escribe `hostname`, `hosts`, `localtime` y `shadow`.
+#
+# **Los seis directorios se garantizan uno por uno** en vez de agregar sólo `/usr/local`: un PATH
+# heredado puede traer unos y no otros, y entonces `su` o `wpa_supplicant` «no existen» porque
+# faltaba `/bin` o `/usr/sbin`. Medido dentro de un chroot de la imagen: con el PATH de la otra
+# máquina, `claude` se encontraba y `su` no.
+#
+# Y los del hogar van con `$HOME`, no con una ruta escrita: este fichero viaja a la máquina de
+# cualquiera. La primera versión de este PATH decía `/home/sergio/...` y eso es justo lo que no
+# puede quedar en algo que instala un desconocido.
+cuenta_perfil_de_login() {
+ _cp_t="$1"
+ [ -e "$_cp_t/etc/profile" ] && { echo " · /etc/profile ya existe: no lo toco"; return 0; }
+ bb mkdir -p "$_cp_t/etc"
+ cat > "$_cp_t/etc/profile" <<'PERFIL'
+# /etc/profile — lo que lee un login shell.
+#
+# El `ash` de busybox sourcea ESTE fichero y nada más: `/etc/profile.d/*` no se lee si acá no se
+# lee. Sin esto, la consola de rescate tampoco tiene PATH.
+for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do
+ case ":${PATH:-}:" in
+ *":$dir:"*) ;;
+ *) PATH="${PATH:+$PATH:}$dir" ;;
+ esac
+done
+# Y los del hogar, al final: lo de la persona no debe tapar lo del sistema.
+for dir in "$HOME/.local/bin" "$HOME/bin"; do
+ [ -d "$dir" ] || continue
+ case ":$PATH:" in *":$dir:"*) ;; *) PATH="$PATH:$dir" ;; esac
+done
+export PATH
+umask 022
+
+for perfil in /etc/profile.d/*.sh; do
+ [ -r "$perfil" ] && . "$perfil"
+done
+unset perfil dir
+PERFIL
+ bb chmod 644 "$_cp_t/etc/profile"
+ echo " · /etc/profile escrito (los seis directorios + los del hogar al final)"
+}
+
+# El mismo `_set_shadow` que ya tenía `takana-live-install.sh`, con otro nombre para no pisárselo.
+_cuenta_set_shadow() {
+ _cs_u="$1"; _cs_h="$2"; _cs_f="$3/etc/shadow"
+ if [ ! -f "$_cs_f" ]; then
+ printf '%s:%s:20000:0:99999:7:::\n' "$_cs_u" "$_cs_h" > "$_cs_f"
+ bb chmod 600 "$_cs_f"
+ return
+ fi
+ bb awk -F: -v u="$_cs_u" -v h="$_cs_h" 'BEGIN{OFS=":"}
+ { if ($1==u){ $2=h; seen=1 } print }
+ END{ if(!seen) print u,h,"20000","0","99999","7","","","" }' "$_cs_f" > "$_cs_f.cuenta-tmp" \
+ && mv "$_cs_f.cuenta-tmp" "$_cs_f"
+ bb chmod 600 "$_cs_f"
+}
diff --git a/scripts/takana-install-partition.sh b/scripts/takana-install-partition.sh
index 5c16148e..ab149c8b 100755
--- a/scripts/takana-install-partition.sh
+++ b/scripts/takana-install-partition.sh
@@ -118,6 +118,10 @@ case "$ON_ACTIVE" in auto|kexec|free-space|abort) ;; *) echo "--on-active-root:
# un disco; estas funciones, sobre directorios, se prueban enteras en un segundo.
. "$(dirname "$0")/lib/usuarios-del-home.sh"
. "$(dirname "$0")/lib/adoptar.sh"
+# La cuenta de quien va a usar la máquina. Este camino —`--keep`/`--adopt`, el más común de todos—
+# no preguntaba NADA: los usuarios salían del `/home` conservado con `!` en shadow y root quedaba
+# cerrado, o sea una máquina instalada e inadministrable. Medido en metal el 2026-10-14.
+. "$(dirname "$0")/lib/cuenta.sh"
SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1
# `e2label` sólo hace falta para `--adopt`, que no formatea: ahí la etiqueta por la que el arranque
# encuentra la raíz hay que ponerla a mano. Se resuelve acá arriba —con el resto— y sólo cuando se
@@ -1139,6 +1143,16 @@ if [ -n "$KEEP_SPEC" ]; then keep_aplicar "$ROOT_TREE"; fi
# dentro de la raíz. Lo único que hace falta es que sus dueños existan en el sistema nuevo.
if [ -n "$ADOPT" ] && [ -s "$ADOPT_USUARIOS" ]; then usuarios_aplicar "$ROOT_TREE" "$ADOPT_USUARIOS"; fi
+# ── LA CUENTA: se pregunta acá y se escribe acá, con el destino TODAVÍA SIN TOCAR ─────────────────
+# El orden importa en las dos direcciones. Después de `usuarios_aplicar`, para que un usuario ya
+# cosechado del `/home` conservado sólo reciba su contraseña en vez de crearse dos veces (ése es el
+# caso del operador: `sergio` existía con el uid de sus archivos y `!` por contraseña). Y antes del
+# `mke2fs`, por el mismo motivo que `keep_aplicar`: si esto falla, nada se destruyó.
+echo "══ la cuenta de quien va a usar la máquina ═══════════════════════════════════"
+cuenta_preguntar "$ROOT_TREE"
+cuenta_aplicar "$ROOT_TREE" \
+ || { echo "takana-install-partition: la cuenta NO quedó escrita; no sigo y NADA se formateó." >&2; exit 1; }
+
# ── 2) Formatear SÓLO la partición destino, y poblarla SIN MONTARLA ────────────────────────────
# `mke2fs -d` escribe el árbol dentro del filesystem nuevo sin `mount` y sin loop. Que no haya
# montaje no es elegancia: un `mount` mal apuntado en un disco ajeno es justamente el accidente que
diff --git a/scripts/takana-live-install.sh b/scripts/takana-live-install.sh
index 1cedd01a..04af8f62 100755
--- a/scripts/takana-live-install.sh
+++ b/scripts/takana-live-install.sh
@@ -88,6 +88,25 @@ read_secret() { # read_secret VAR "prompt" (sin eco si hay stty)
have_cryptpw() { bb 2>&1 | grep -qw cryptpw; }
+# ── LA CUENTA DE QUIEN VA A USAR LA MÁQUINA (lib/cuenta.sh) ───────────────────────────────────────
+# Este instalador preguntaba hostname, zona, teclado y la credencial de ROOT — y ningún usuario. En
+# una imagen de escritorio eso es una máquina donde el greeter no puede autenticar a nadie, porque
+# no hay nadie. La sección es compartida con `takana-install-partition.sh` y vive en la librería:
+# son DOS caminos y UNA pregunta, el mismo argumento que escribe `lib/usuarios-del-home.sh`.
+#
+# ⚠ Se busca en dos sitios porque este script **se inyecta como `/usr/bin/takana-install`** dentro
+# del producto (`iso-image.sh`), donde `scripts/lib/` no existe: ahí la librería viaja a
+# `/usr/lib/takana/cuenta.sh`. Y si no viajó, no se muere el instalador — se dice en el resumen,
+# que es donde alguien lo va a leer antes de apretar.
+for _lib_cuenta in "$(dirname "$0")/lib/cuenta.sh" /usr/lib/takana/cuenta.sh; do
+ [ -r "$_lib_cuenta" ] && { . "$_lib_cuenta"; break; }
+done
+if ! command -v cuenta_preguntar >/dev/null 2>&1; then
+ cuenta_preguntar() { :; }
+ cuenta_aplicar() { :; }
+ cuenta_resumen() { printf 'NINGUNA — lib/cuenta.sh no viajó en este medio'; }
+fi
+
# Reescribe el campo de contraseña de un usuario en $mnt/etc/shadow (o crea el fichero).
_set_shadow() {
_u="$1"; _h="$2"; _f="$3/etc/shadow"
@@ -113,6 +132,11 @@ apply_post_install_config() {
fi
fi
if [ -n "${CFG_ROOT_HASH:-}" ]; then _set_shadow root "$CFG_ROOT_HASH" "$_mnt"; fi
+ # La cuenta: usuario, su contraseña, su hogar, los grupos del hardware y la regla de doas. Lleva
+ # su propio brazo de control adentro y devuelve ≠0 si no quedó escrita — se dice fuerte, porque
+ # una cuenta que no quedó sólo se descubre al primer arranque, y arreglarla desde ahí pide montar
+ # el disco desde otro sistema.
+ cuenta_aplicar "$_mnt" || echo " ⚠ LA CUENTA NO QUEDÓ ESCRITA: revisá /etc/passwd y /etc/shadow antes de reiniciar" >&2
if [ -n "${CFG_ROOT_AUTHKEYS:-}" ]; then
bb mkdir -p "$_mnt/root/.ssh"; bb chmod 700 "$_mnt/root/.ssh"
printf '%s\n' "$CFG_ROOT_AUTHKEYS" >> "$_mnt/root/.ssh/authorized_keys"
@@ -212,6 +236,17 @@ interactive_setup() {
*) echo " (root sin credencial nueva)" ;;
esac
+ # --- la cuenta de quien va a usar la máquina ---
+ # Va después de la credencial de root y le pasa lo ya decidido, para no preguntar dos veces por
+ # lo mismo: la librería sólo insiste con root si NADIE puede escalar, que es la única
+ # combinación que deja una máquina inadministrable.
+ CUENTA_ROOT_HASH="$CFG_ROOT_HASH"
+ # La raíz que se MIRA para saber si hay con qué escalar. En producción es "/" —el medio live es
+ # el sistema que se copia—; en el arnés es el rootfs de scratch, porque si no el test
+ # dependería de si la máquina que lo corre tiene sudo instalado.
+ cuenta_preguntar "${TAKANA_INSTALL_TESTROOT:-${HAMMER_INSTALL_TESTROOT:-/}}"
+ CFG_ROOT_HASH="$CUENTA_ROOT_HASH"
+
# --- locale (best-effort) ---
_zi="${ZONEINFO:-/usr/share/zoneinfo}"
if [ -d "$_zi" ]; then
@@ -232,6 +267,7 @@ interactive_setup() {
echo " / = ${ROOT_MB} MiB /store = ${STORE_MB} MiB /var/lib/hammer = resto"
echo " hostname: $CFG_HOSTNAME"
echo " root : $_cd"
+ echo " usuario : $(cuenta_resumen)"
echo " zona : ${CFG_TZ:-UTC} teclado: $CFG_KEYMAP"
echo
echo " ⚠ ESTO BORRA TODO EN $DEV."