> 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…
bootstrap: arje-zero como PID 1 de Stage 1 (I2 del SDD 12)
Reemplaza el init provisional de busybox por arje-zero como PID 1 — el paso
"init real" que entrega el CRASHED real (Fase 5 diferida, ADR 0007).
- STAGE1_COMPONENTS += arje-zero (la receta puente ya lo construía)
- assemble_rootfs: genera /ente/seed.card.json (template autocontenido), crea
/sbin/init -> /usr/bin/arje-zero, y los mount points que arje monta (incl.
/ente, /var/lib/hammer, /sys/fs/cgroup, /dev/pts, /dev/shm). Se retira el inittab.
- la seed declara hammerd como Payload::Native Restart (su on_death = el CRASHED
real) + console-getty supervisada; ULIDs fijos ⇒ RootfsHash reproducible (v2)
- la seed se VERIFICÓ contra el tipo real card_core::Card (from_json + validate
pasan), no sólo como JSON — evidencia, no aserción
- +2 tests (ensamblado con arje init + seed válida); 21 verdes en el crate
Falta para cerrar Stage 1: bus único (B.2) y atestación (A1/A2). Boot real en VM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs
index be5a8f6a..b32b7773 100644
--- a/crates/hammer-bootstrap/src/lib.rs
+++ b/crates/hammer-bootstrap/src/lib.rs
@@ -165,26 +165,91 @@ pub fn seed_build_config(
/// mismos artefactos producen el mismo hash aunque difieran en inodes o timestamps.
pub type RootfsHash = ArtifactHash;
-/// Los componentes del userland mínimo, en orden de ensamblado. `musl` primero (libc), `busybox`
-/// (coreutils + sh + init), `hammerd` (daemon, receta Cargo). El init `arje` (ADR 0007) reemplaza
-/// al de busybox en un lote posterior; hasta entonces el PID 1 provisional es el `init` de busybox
-/// que arranca a `hammerd` por `/etc/inittab`.
-const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd"];
-
-/// `inittab` del init provisional (busybox). Monta los pseudo-FS, arranca `hammerd` como servicio
-/// respawn y abre una shell. Cuando entre `arje` como PID 1 (ADR 0007) este archivo se reemplaza
-/// por su supervisión real (que entrega el `CRASHED` que la Fase 5 dejó diferido).
-const PROVISIONAL_INITTAB: &str = "\
-# Stage 1 — init provisional (busybox). Reemplazado por arje (ADR 0007) más adelante.
-::sysinit:/bin/mount -t proc proc /proc
-::sysinit:/bin/mount -t sysfs sysfs /sys
-::sysinit:/bin/mount -t devtmpfs dev /dev
-::sysinit:/bin/mount -o remount,rw /
-::respawn:/usr/bin/hammerd
-::respawn:/bin/sh
-::ctrlaltdel:/bin/umount -a -r
-::shutdown:/bin/umount -a -r
-";
+/// Los componentes del userland mínimo, en orden de ensamblado: `musl` (libc), `busybox`
+/// (coreutils + sh), `hammerd` (daemon, receta Cargo) y `arje-zero` (el init PID 1, ADR 0007).
+/// arje-zero es PID 1 vía el symlink `/sbin/init`; supervisa a `hammerd` y a la getty por la
+/// seed card ([SDD 12](../../docs/12-init-real.md)) — su muerte es el `CRASHED` real.
+const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd", "arje-zero"];
+
+/// La **seed card** que arje-zero lee en `/ente/seed.card.json` al boot ([SDD 12 §4](../../docs/12-init-real.md)):
+/// un `Card` `Virtual` cuyos `genesis` son `hammerd` (servicio `Native` con `Restart` — el
+/// `CRASHED` real) y una `console-getty` supervisada. La forma replica una seed real de arje
+/// (`seeds/arje-qemu.card.json`) para que `Card::validate()` la acepte; los ULID son fijos para que
+/// el `RootfsHash` sea reproducible. Template autocontenido (opción A del SDD): hammer no depende de
+/// `card-core` como librería; el pin a arje-zero + el boot en VM cubren el drift de schema.
+const STAGE1_SEED_CARD: &str = r#"{
+ "schema_version": 1,
+ "id": "01HQAR53D4M2NBV8KZTYXFQA00",
+ "lineage": null,
+ "label": "hammer-stage1",
+ "provides": ["Spawn", "Journal"],
+ "requires": [],
+ "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
+ "soma": {
+ "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
+ "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
+ "cgroup": { "path": "arje.slice/hammer", "cpu_weight": null, "io_weight": null },
+ "cpu_affinity": null
+ },
+ "payload": "Virtual",
+ "supervision": "OneShot",
+ "lifecycle": "daemon",
+ "priority": "normal",
+ "flow": { "input": [], "output": [] },
+ "genesis": [
+ {
+ "schema_version": 1,
+ "id": "01HQAR53D4M2NBV8KZTYXFQA01",
+ "lineage": null,
+ "label": "hammerd",
+ "provides": [],
+ "requires": [],
+ "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
+ "soma": {
+ "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
+ "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
+ "cgroup": { "path": "arje.slice/hammerd", "cpu_weight": null, "io_weight": null },
+ "cpu_affinity": null
+ },
+ "payload": { "Native": {
+ "exec": "/usr/bin/hammerd",
+ "argv": ["--store", "/store", "--journal", "/var/lib/hammer/journal"],
+ "envp": [["RUST_LOG", "info"], ["ENTE_BUS_SOCK", "/run/ente-bus.sock"]]
+ }},
+ "supervision": { "Restart": { "initial": 200, "max": 10000 } },
+ "lifecycle": "daemon",
+ "priority": "normal",
+ "flow": { "input": [], "output": [] },
+ "genesis": []
+ },
+ {
+ "schema_version": 1,
+ "id": "01HQAR53D4M2NBV8KZTYXFQA02",
+ "lineage": null,
+ "label": "console-getty",
+ "provides": [],
+ "requires": [],
+ "permissions": { "networking": "none", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
+ "soma": {
+ "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
+ "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
+ "cgroup": { "path": "arje.slice/getty", "cpu_weight": null, "io_weight": null },
+ "cpu_affinity": null
+ },
+ "payload": { "Native": {
+ "exec": "/bin/busybox",
+ "argv": ["getty", "-n", "-l", "/bin/sh", "115200", "console"],
+ "envp": []
+ }},
+ "supervision": { "Restart": { "initial": 200, "max": 10000 } },
+ "lifecycle": "daemon",
+ "priority": "normal",
+ "flow": { "input": [], "output": [] },
+ "genesis": []
+ }
+ ]
+}
+"#;
/// Qué construir para Stage 1: la semilla ya sellada (por `(seed_hash, seed_kind)`) y el directorio
/// de recetas donde viven `musl.toml` / `busybox.toml`.
@@ -255,15 +320,16 @@ pub fn stage1(
Ok(rootfs_hash)
}
-/// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + el
-/// init provisional. Cambiar un componente, el orden o el inittab re-hashea el rootfs.
+/// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + la
+/// seed card del init. Cambiar un componente, el orden o la seed re-hashea el rootfs. El tag `v2`
+/// marca el corte respecto al rootfs con init provisional (`v1`).
fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash {
- let mut inputs: Vec<Vec<u8>> = vec![b"hammer-stage1-rootfs-v1".to_vec()];
+ let mut inputs: Vec<Vec<u8>> = vec![b"hammer-stage1-rootfs-v2".to_vec()];
for (name, h) in components {
inputs.push(name.as_bytes().to_vec());
inputs.push(h.as_str().as_bytes().to_vec());
}
- inputs.push(PROVISIONAL_INITTAB.as_bytes().to_vec());
+ inputs.push(STAGE1_SEED_CARD.as_bytes().to_vec());
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
ArtifactHash::of_inputs(&refs)
}
@@ -282,14 +348,19 @@ fn assemble_and_seal(
}
/// Ensambla el árbol FHS del rootfs en `staging`: esqueleto de directorios, hidratación de cada
-/// componente sellado (hardlinks) y el init provisional. Es la pieza testeable sin build real.
+/// componente sellado (hardlinks), la seed card de arje y `/sbin/init`→arje-zero. Es la pieza
+/// testeable sin build real ([SDD 12](../../docs/12-init-real.md)).
fn assemble_rootfs(
store: &Store,
components: &[(String, ArtifactHash)],
staging: &Path,
) -> Result<()> {
- // Esqueleto FHS mínimo. Los pseudo-FS quedan como puntos de montaje vacíos.
- for d in ["proc", "sys", "dev", "tmp", "run", "etc", "root", "var", "usr/bin", "bin", "sbin"] {
+ // Esqueleto FHS. arje-zero monta él mismo los pseudo-FS (arje-kernel), así que sólo deben
+ // existir como puntos de montaje vacíos; `/ente` lleva la seed, `/var/lib/hammer` el diario.
+ for d in [
+ "proc", "sys", "sys/fs/cgroup", "dev", "dev/pts", "dev/shm", "run", "tmp", "etc", "root",
+ "var", "var/lib/hammer", "ente", "usr/bin", "bin", "sbin",
+ ] {
std::fs::create_dir_all(staging.join(d))?;
}
@@ -304,8 +375,14 @@ fn assemble_rootfs(
// componente lo escribió último.
let _ = std::fs::remove_dir_all(staging.join(".hammer"));
- // Init provisional (busybox lo lee de /etc/inittab).
- std::fs::write(staging.join("etc/inittab"), PROVISIONAL_INITTAB)?;
+ // La seed card que arje-zero lee al boot.
+ std::fs::write(staging.join("ente/seed.card.json"), STAGE1_SEED_CARD)?;
+
+ // arje-zero es PID 1 vía `/sbin/init`. Symlink absoluto: el kernel lo sigue sea cual sea el
+ // cmdline, sin exigir `init=`. Idempotente: si ya existe (re-ensamblado), lo reemplazamos.
+ let init_link = staging.join("sbin/init");
+ let _ = std::fs::remove_file(&init_link);
+ std::os::unix::fs::symlink("/usr/bin/arje-zero", &init_link)?;
Ok(())
}
@@ -642,7 +719,7 @@ mod tests {
}
#[test]
- fn assemble_rootfs_hydrates_and_writes_provisional_init() {
+ fn assemble_rootfs_hydrates_and_installs_arje_init() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path().join("store")).unwrap();
@@ -658,24 +735,55 @@ mod tests {
std::fs::create_dir_all(w.join(".hammer")).unwrap();
std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap();
});
+ let harje = seal_component(&store, "arje-zero", "cc33", |w| {
+ std::fs::create_dir_all(w.join("usr/bin")).unwrap();
+ std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap();
+ });
let staging = store.root().join("staging");
std::fs::create_dir_all(&staging).unwrap();
- let components = [("musl".to_string(), hmusl), ("busybox".to_string(), hbb)];
+ let components = [
+ ("musl".to_string(), hmusl),
+ ("busybox".to_string(), hbb),
+ ("arje-zero".to_string(), harje),
+ ];
assemble_rootfs(&store, &components, &staging).unwrap();
assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado");
assert!(staging.join("bin/busybox").is_file(), "busybox hidratado");
+ assert!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado");
+ // arje-zero es PID 1 vía /sbin/init.
assert_eq!(
- std::fs::read_link(staging.join("bin/sh")).unwrap(),
- std::path::PathBuf::from("busybox"),
- "el symlink de applet se replica literal"
+ std::fs::read_link(staging.join("sbin/init")).unwrap(),
+ std::path::PathBuf::from("/usr/bin/arje-zero"),
+ "/sbin/init apunta a arje-zero"
);
- assert!(staging.join("proc").is_dir() && staging.join("sbin").is_dir(), "esqueleto FHS");
- let inittab = std::fs::read_to_string(staging.join("etc/inittab")).unwrap();
- assert!(inittab.contains("/bin/sh"), "init provisional abre una shell");
- assert!(inittab.contains("mount -t proc"), "monta los pseudo-FS");
+ // La seed card está en su lugar y es JSON válido con los genesis esperados.
+ let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
+ let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
+ assert_eq!(v["label"], "hammer-stage1");
+ let labels: Vec<&str> = v["genesis"].as_array().unwrap()
+ .iter().map(|g| g["label"].as_str().unwrap()).collect();
+ assert_eq!(labels, vec!["hammerd", "console-getty"]);
+ // Puntos de montaje que arje monta solos, presentes como dirs vacíos.
+ assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir());
assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs");
+ // Ya no hay init provisional de busybox.
+ assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado");
+ }
+
+ #[test]
+ fn stage1_seed_card_is_valid_json_with_native_hammerd() {
+ // La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart
+ // (el CRASHED real) — un template roto rompería el boot de arje-zero.
+ let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap();
+ assert_eq!(v["payload"], "Virtual", "la semilla es Virtual");
+ let hammerd = &v["genesis"][0];
+ assert_eq!(hammerd["label"], "hammerd");
+ assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd");
+ assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart");
+ let getty = &v["genesis"][1];
+ assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox");
}
#[test]
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 19cb134c..5245dead 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -185,11 +185,13 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume
idempotente. Cubierto por 4 unit + 1 e2e de CLI (offline, `file://`).
- **Stage 1** ◑ — userland mínimo cross-compilado. Hecho: recetas pinned `musl` 1.2.5 +
`busybox` 1.36.1 (estáticas, zig cc) + `hammerd` (receta Cargo: repo pinned + deps
- vendoreadas en el fetch para build `--offline`); `hammer bootstrap stage1` las construye con
- la semilla, ensambla el rootfs FHS (hardlink) y lo sella con un `RootfsHash` de contenido +
- init provisional (busybox/inittab que arranca `hammerd`). Falta sólo sustituir el init por
- `arje`. El cross-compile real se valida en la VM (los e2e se saltan sin `.dev-fs`); el
- manifiesto anota la línea de Stage 1.
+ vendoreadas en el fetch para build `--offline`) + `arje-zero` (init PID 1, receta Cargo a
+ tawasuyu); `hammer bootstrap stage1` los construye con la semilla, ensambla el rootfs FHS
+ (hardlink) y lo sella con un `RootfsHash` de contenido. El init es **arje-zero como PID 1**
+ (`/sbin/init`→arje-zero) con su seed card `/ente/seed.card.json` (hammerd + getty supervisados
+ ⇒ el `CRASHED` real) — diseño en [SDD 12](12-init-real.md), seed verificada contra
+ `card_core::Card`. Falta: bus único (B.2) y atestación (A1/A2). El cross-compile y el boot
+ reales se validan en la VM; el manifiesto anota la línea de Stage 1.
- **Stage 2** ☐ — rebuild nativo dentro del rootfs y diff de hashes ⇒ auto-alojamiento
bit-reproducible.
- Reemplazar el init de Alpine por **tu init** (bus por pipes nativo) — habilita el `CRASHED`
diff --git a/docs/12-init-real.md b/docs/12-init-real.md
index bbfa2ac7..40ba3db8 100644
--- a/docs/12-init-real.md
+++ b/docs/12-init-real.md
@@ -134,11 +134,13 @@ descarta: acoplar los grafos de build de los dos repos es justo lo que C.3 evita
## 6. Fases de implementación (riesgo creciente)
- **I1 — arje-zero construible.** ✅ (`recipes/arje-zero.toml`, receta puente).
-- **I2 — arje es PID 1 de Stage 1.** `STAGE1_COMPONENTS += arje-zero`; `assemble_rootfs` genera
- `/ente/seed.card.json` (§4, opción A), provee los mount points + `/sbin/init`→arje-zero, y retira
+- **I2 — arje es PID 1 de Stage 1.** ✅ `STAGE1_COMPONENTS += arje-zero`; `assemble_rootfs` genera
+ `/ente/seed.card.json` (§4, opción A), provee los mount points (incl. `/ente`, `/var/lib/hammer`,
+ `/sys/fs/cgroup`, `/dev/pts`, `/dev/shm`) + `/sbin/init`→`/usr/bin/arje-zero`, y ya **no** escribe
el `/etc/inittab` provisional. arje supervisa hammerd ⇒ **CRASHED real manejado por el init**.
- Validación: boot en VM (no testeable sin display/kernel; los unit tests cubren la generación de la
- seed y el ensamblado, como el resto de Stage 1).
+ La seed embebida se **verificó contra el tipo real** `card_core::Card` (`from_json` + `validate()`
+ pasan), no sólo como JSON válido. El boot completo se valida en VM (no testeable sin kernel); los
+ unit tests cubren la seed y el ensamblado, como el resto de Stage 1.
- **I3 — bus único (plan B.2).** hammerd `announce` en `arje-bus` (cambio en hammerd) y **expone el
`CRASHED` a la capa de IA** de hammer (`agent.sock` emite `{"t":"crashed",…}`). El `agent.sock`
(JSON, API de IA) queda **encima**; `arje-bus` (postcard) es el plano de control del init.