fix(simi,perfiles): NINGÚN perfil proveía `/bin/sh` — funcionaba por la capa de debajo
Buscando restos de busybox apareció una ausencia. Medido sobre el cierre de IMAGEN de `cli` (154
nodos), preguntando a cada artefacto del store si instala el fichero:
bin/bash ← bash
usr/bin/awk ← akllay
usr/bin/doas ← doas
bin/login ← shadow
sbin/agetty ← util-linux
bin/sh ← NADIE
`bash` instala `/bin/bash` y nada más, y `hydrate-profile.py` proyecta SÓLO el cierre del perfil:
no parte de ningún rootfs base. Así que una imagen armada únicamente desde un perfil no tiene
`/bin/sh` — todo script con `#!/bin/sh` falla y el login de consola, que es `agetty -l /bin/sh`,
no tiene qué ejecutar.
Funcionaba porque **siempre había una capa debajo que lo traía**: el `product-rootfs` en el
sistema instalado (ya con simi desde que se quitó busybox), y `work/metal-rootfs` —«arje-zero PID1
+ busybox», lo dice su propia cabecera— en las imágenes de escritorio de prueba. Una dependencia
oculta en el orden de apilado, de las que se descubren el día que alguien hidrata un perfil solo.
Dos cambios:
- `simi` entra en `perfil.base`, de donde heredan `cli`, los cuatro escritorios, `servidor` y
`servidor-minimo`. No estaba en NINGÚN perfil, y es la shell.
- la receta de simi instala `/bin/sh → /usr/bin/simi`. El enlace lo pone el PROVEEDOR, no el
hidratador: quien da la capacidad da su nombre canónico, como `os-release` con
`/usr/lib/os-release`. Con eso el `sh` sale del propio perfil y deja de depender de qué haya
debajo.
El commit pineado sube de `6dcaff42` a `4bc9be4d`: el artefacto sellado era simi de antes de los
ficheros de arranque, `alias`, `getopts` y el arreglo del lexer. Y el guardián lo AFIRMA
ejecutando el binario, no mirando su tamaño — porque «sella» no prueba que corra, y en este
corpus eso ya pasó más de una vez:
simi -c 'alias x=echo; x alias-ok' ⇒ alias-ok
simi -c 'set -- -a; while getopts a o; do …' ⇒ ok=a
simi -c 'v="|a"; [ "${v#|}" = a ]' ⇒ 0
El de `/bin/sh` usa `-L`, no `-e`: el enlace es absoluto y dentro del artefacto apunta fuera, así
que `-e` lo sigue hasta un sitio que no existe y diría AUSENTE sobre un enlace correcto. Ya me
mordió antes en esta misma caja.
Sella en `b645f960…`, 561.592 B, y reproduce. Con esto `base` y `cli` pasan a tener clausura de
imagen COMPLETA: 76 nodos `sha256:38d6f1a4…` y 155 nodos `sha256:72616237…`.
`consola` también entra en `servidor-minimo`: son siete ficheros de configuración y ni un binario,
y un servidor mínimo sin `LANG`, con el prompt pelado y la campana en cada Tab es el peor caso de
los que hay.
El plan de botar busybox gana el § 4 con los DOS restos que quedan y que no fallan ruidosamente:
`work/metal-rootfs` —la base de todas las imágenes de escritorio— y `mirada-usb.sh`, que copia
busybox **de Alpine** (`$DEVFS/bin/busybox`) y escribe un `/sbin/init` que lo invoca. Ese segundo
es peor que un resto propio: es un binario ajeno que no está en el store ni entra en ningún hash.
Cambiarlo es su propia unidad de trabajo — el mismo script toma de ahí el loader de musl y arma
mesa con `apk`.
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
3 archivos · +136 −1
- docs/plan-botar-busybox.md +70 −0
- docs/state/targets.toml +14 −0
- recipes/simi.toml +52 −1
diff --git a/docs/plan-botar-busybox.md b/docs/plan-botar-busybox.md
index cf8ba67d..acfdae95 100644
--- a/docs/plan-botar-busybox.md
+++ b/docs/plan-botar-busybox.md
@@ -2036,3 +2036,73 @@ tiene kernel propio que ofrecer.
distro**: el worker tiene los 16 G pero es un LXC sin kernel propio; el hub y gioser tienen kernel
pero ~7 G, y la VM sola pide 8. Mientras tanto, lo que está verificado es el cruce entre máquinas
(el mismo `of_tree` calculado en hub y worker), no el rebuild de adentro.
+
+---
+
+## 4 · Los DOS restos que quedan, medidos el 2026-10-23
+
+El barrido quitó busybox de la **raíz de confianza del arranque** (`STAGE1_COMPONENTS`,
+`ATTEST_PATHS`, `/bin/sh → /usr/bin/simi`) y el `product-rootfs` sellado lo confirma: no hay
+`bin/busybox` y el núcleo ENCOGIÓ, 964 K → 530 K. Pero busybox sigue vivo en dos sitios que el
+barrido no tocó, y ninguno falla ruidosamente, así que conviene dejarlos escritos.
+
+### 4.1 · `work/metal-rootfs`: la base de TODAS las imágenes de escritorio
+
+`scripts/{kde,gnome,cosmic}/qemu-desktop-image.sh` y `metal-desktop-image.sh` describen su base
+igual, en su propia cabecera:
+
+```
+# work/metal-rootfs base: arje-zero PID1 + busybox + kernel(EFI-stub) + firmware
+```
+
+Y sobre eso apilan el cierre del perfil hidratado. O sea que **el `/bin/sh` y las utilidades de
+las imágenes de escritorio de prueba siguen siendo busybox**, no simi. No es el sistema instalado
+—ése es el `product-rootfs` copiado, y ya va con simi— pero sí es lo que se arranca en cada
+verificación de escritorio.
+
+### 4.2 · `mirada-usb.sh` trae busybox DE ALPINE
+
+Peor que un resto propio: es ajeno.
+
+```sh
+cp -a "$DEVFS"/bin/busybox "$RFS"/bin/busybox # $DEVFS = .dev-fs/alpine
+for a in …; do ln -sf busybox "$RFS/bin/$a"; done
+ln -sf /bin/busybox "$RFS/sbin/init.busybox"
+```
+
+El `/sbin/init` que ese script escribe es un `#!/bin/sh` que invoca `/bin/busybox mount …`. El
+USB live de mirada —el que lleva el instalador `churay`— depende por tanto de un binario de
+Alpine que no está en el store ni entra en ningún hash. Cambiarlo es su propia unidad de trabajo,
+porque el mismo script también toma de ahí el loader de musl y arma mesa con `apk`.
+
+### 4.3 · El hueco que esto destapó: NINGÚN perfil proveía `sh`
+
+Buscando lo de arriba apareció algo que no es un resto, es una ausencia. Medido sobre el cierre de
+IMAGEN de `cli` (154 nodos), preguntando a cada artefacto del store si instala el fichero:
+
+```
+bin/bash ← bash
+usr/bin/awk ← akllay (el awk propio, en quechua — por eso un censo por nombres de
+ upstream decía «no hay awk», y era falso)
+usr/bin/doas ← doas (por eso `sudo` ausente tampoco es un hueco)
+bin/login ← shadow
+sbin/agetty ← util-linux
+bin/sh ← NADIE
+```
+
+`bash` instala `/bin/bash` y nada más; `hydrate-profile.py` proyecta SÓLO el cierre del perfil y
+no parte de ningún rootfs base. Así que una imagen armada únicamente desde un perfil no tiene
+`/bin/sh`: todo script con `#!/bin/sh` falla y `agetty -l /bin/sh` no tiene qué ejecutar.
+
+Funcionaba porque **siempre había una capa debajo que lo traía** — el `product-rootfs` en el
+sistema instalado, `metal-rootfs` con su busybox en las imágenes de escritorio. Una dependencia
+oculta en el orden de apilado, que es la clase de cosa que se descubre el día que alguien hidrata
+un perfil solo.
+
+⇒ Arreglado declarando `simi` en `perfil.base` —de donde heredan `cli`, los cuatro escritorios,
+`servidor` y `servidor-minimo`— y haciendo que **la receta de simi instale `/bin/sh`**: quien
+provee la capacidad provee su nombre canónico, como `os-release` con `/usr/lib/os-release`. Con
+eso el `sh` sale del propio perfil y deja de depender de qué haya debajo.
+
+`escritorio-mirada` NO hereda de nada (es delgado a propósito) y sigue sin shell en su cierre:
+ahí el shell lo pone `mirada-usb.sh`, que es el § 4.2.
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 9f6193a8..19c434fb 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -179,6 +179,14 @@ paquetes = [
# imagen; el bit se declara en `recipes/doas.toml` (`[[setuid]]`) y lo pone el ensamblado.
# ⚠ Falta el `/etc/doas.conf`: sin él doas rechaza todo. Es la unidad siguiente y está anotada en
# la receta — no se inventa acá una regla de quién puede escalar.
+ # ⚠ `simi` PRIMERO, y no es cosmético: es `/bin/sh`. Faltaba, y la consecuencia era grande y
+ # silenciosa — medido el 2026-10-23 sobre el cierre de IMAGEN de `cli` (154 nodos), NINGÚN
+ # paquete proveía `sh`: `bash` instala `/bin/bash` y nada más, y `hydrate-profile.py` proyecta
+ # sólo el cierre del perfil, no parte del rootfs de arranque. O sea que todo script con
+ # `#!/bin/sh` fallaba en la imagen y el login de consola —`agetty -l /bin/sh`— no tenía qué
+ # ejecutar. El rootfs de ARRANQUE sí lo tenía desde que se quitó busybox; la imagen del perfil
+ # no. Dos fuentes de verdad que discrepaban sin que nada lo dijera.
+ "simi",
"bash", "bash-completion", "git", "doas", "util-linux", "shadow", "kbd", "procps-ng",
"iproute2", "iputils", "dhcpcd", "e2fsprogs", "dosfstools", "parted", "rsync",
"sed", "tzdata", "vim", "dbus", "ca-certificates", "gnupg", "lsof", "strace",
@@ -3184,6 +3192,12 @@ hereda = ["base"]
# una opinión sobre qué debe llevar un servidor. Si algún día el instalador deja de cargar el
# sistema en RAM, este perfil deja de tener motivo.
paquetes = [
+ # `consola` también acá, y no es un descuido del adelgazado: son SIETE ficheros de
+ # configuración y ni un binario, y un servidor mínimo sin `LANG`, con el prompt pelado y la
+ # campana sonando en cada Tab es justo el peor caso de los que hay. Todas sus menciones de
+ # herramientas van tras un `command -v`, así que acá —donde no hay starship ni eza— da lo que
+ # sí se puede dar: UTF-8, `EDITOR`, `PAGER`, historial con memoria, `inputrc` y LS_COLORS.
+ "consola",
# ── BAJAR PRIVILEGIOS (2026-10-22) ───────────────────────────────────────────────────────────
# `setuidgid` lo invocan las cards de servicio de este perfil por ruta absoluta: lo necesitan
# `gitea`, `minga`, `pacha`, `pacha-secretos` y `shuma-daemon`, porque esos daemons se niegan a
diff --git a/recipes/simi.toml b/recipes/simi.toml
index 4fb8af2d..dc2145a3 100644
--- a/recipes/simi.toml
+++ b/recipes/simi.toml
@@ -57,7 +57,7 @@ license = "MIT"
[source]
# El propio takana. El commit es el identificador inmutable; subirlo cuando simi avance.
repo = "https://git.gioser.net/sergio/takana.git"
-commit = "6dcaff42842f8e26575fbe3bd26a466bc6b9c754"
+commit = "4bc9be4d066f48c9eaa70736d1f18e01f43da87a"
[build]
compiler = "zig-cc"
@@ -68,6 +68,57 @@ link = "static"
strip_debug = true
# Sólo el binario `simi` del workspace.
flags = ["-p", "simi"]
+# ⚠ La receta no instalaba `/bin/sh`, y la consecuencia era GRANDE y silenciosa: medido el
+# 2026-10-23 sobre el cierre de IMAGEN de `cli` (154 nodos), NINGÚN paquete provee `sh`.
+#
+# bin/bash ← bash
+# usr/bin/awk ← akllay
+# usr/bin/doas ← doas
+# bin/login ← shadow
+# sbin/agetty ← util-linux
+# bin/sh ← NADIE
+#
+# O sea que en una imagen hidratada todo script con `#!/bin/sh` falla, y el login de consola
+# —que es `agetty -l /bin/sh`— no tiene qué ejecutar. El rootfs de ARRANQUE sí lo tiene (el
+# bootstrap crea `bin/sh → /usr/bin/simi` desde que se quitó busybox), pero la imagen de un
+# perfil la arma `hydrate-profile.py`, que proyecta SÓLO el cierre del perfil y no parte del
+# rootfs de arranque. Dos fuentes de verdad que discrepaban, que es exactamente el modo de fallo
+# que la cabecera de ese script describe.
+#
+# El enlace lo pone el PROVEEDOR, no el hidratador: quien da la capacidad da su nombre canónico.
+# Es lo mismo que hace `os-release` con `/usr/lib/os-release`.
+[build.phases]
+install = '''
+set -eu
+mkdir -p /out/usr/bin /out/bin
+# Replica lo que la fase derivada de Cargo hace —copiar los ejecutables de `target/*/release`—
+# porque declarar `install` la REEMPLAZA, no la amplía. Se prueban las dos rutas (nativa del
+# sandbox y por triple) en vez de hornear una: cuál aplica depende de `build.target`.
+for rel in target/release target/*/release; do
+ [ -d "$rel" ] || continue
+ find "$rel" -maxdepth 1 -type f -perm -100 -exec cp {} /out/usr/bin/ \;
+done
+ln -sfn /usr/bin/simi /out/bin/sh
+
+# ── guardián ─────────────────────────────────────────────────────────────────────────────────
+[ -x /out/usr/bin/simi ] || { echo "simi: no quedó el ejecutable — NO sello" >&2; exit 1; }
+# `-L` y no `-e`: el enlace es ABSOLUTO y dentro del artefacto apunta fuera, así que `-e` lo
+# sigue hasta un sitio que no existe y dice AUSENTE sobre un enlace correcto. Ya me mordió.
+[ -L /out/bin/sh ] || { echo "simi: falta /bin/sh — la imagen quedaría sin shell; NO sello" >&2; exit 1; }
+[ "$(readlink /out/bin/sh)" = /usr/bin/simi ] || {
+ echo "simi: /bin/sh apunta a $(readlink /out/bin/sh), no a /usr/bin/simi — NO sello" >&2; exit 1; }
+
+# Y que CORRA, con las tres capacidades que el commit pineado tiene que traer. Un binario que
+# sella y no corre ya pasó en este corpus más de una vez.
+/out/usr/bin/simi -c 'alias x=echo; x alias-ok' | grep -q alias-ok || {
+ echo "simi: el binario no trae alias — el commit pineado es viejo; NO sello" >&2; exit 1; }
+/out/usr/bin/simi -c 'set -- -a; while getopts a o; do echo "ok=$o"; done' | grep -q 'ok=a' || {
+ echo "simi: el binario no trae getopts — el commit pineado es viejo; NO sello" >&2; exit 1; }
+/out/usr/bin/simi -c 'v="|a"; [ "${v#|}" = a ]' || {
+ echo "simi: el operador # con patron | sigue roto — el commit pineado es viejo; NO sello" >&2; exit 1; }
+echo "simi: $(stat -c%s /out/usr/bin/simi) B · /bin/sh -> $(readlink /out/bin/sh) · alias+getopts OK"
+'''
+
# Sin `[build.phases]`: igual que hammerd y netup, el BuildSys::Cargo del lab instala el binario del
# crate. **El symlink `sh` NO lo pone esta receta**, y eso es a propósito: quién gana como `/bin/sh`