Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion crates/thalyx-cli/src/catalogue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,21 @@ pub const VERBS: &[Verb] = &[
summary: "What a name is — kind, crate, signature, where, how many uses — small \
enough to read, with a handle that fetches the exact lines on demand.",
},
// Beside `context` because it answers the question `context` cannot: not
// *what is this name* but *can this machine resolve a name at all*. A run
// that asks the first without ever asking the second is the 2026-08-30
// benchmark, which paid for a machine that had no compiler on it.
Verb {
id: "toolchain",
names: &["herramientas", "toolchain"],
takes: &[],
flags: &[],
answers: Some("toolchain"),
changes: false,
errors: &[],
summary: "Whether this machine can resolve Rust names: which cargo and \
rust-analyzer it has, whether they ran, and whose they are.",
},
Verb {
id: "rename",
names: &["renombrar-simbolo", "renombrar-símbolo", "rename"],
Expand Down Expand Up @@ -946,7 +961,19 @@ mod tests {
// it had. They are the frontier agent's verbs — typed, or sent over the
// external surface, where the caller has already read the exact name
// out of an answer this machine gave it.
const NOT_A_SENTENCE: [&str; 4] = ["exec", "evidence", "context", "rename"];
// `toolchain` joins them for a different reason from the other four:
// not that a sentence cannot spell its argument — it takes none — but
// that it is a question about the machine's own equipment rather than
// about the human's request. Its callers are a preflight deciding
// whether to spend money and a person typing `herramientas`; teaching
// the local grammar a verb neither of those goes through would spend a
// tiny model's tokens on a word nobody says to it.
//
// Written down as a pendiente rather than as a closed question:
// «¿puedes renombrar símbolos aquí?» is a thing Cesar might well say
// out loud, and the day it is worth answering, this list is where the
// decision is recorded.
const NOT_A_SENTENCE: [&str; 5] = ["exec", "evidence", "context", "rename", "toolchain"];

let unaskable: Vec<&&str> = ops
.difference(&proposable)
Expand Down
109 changes: 109 additions & 0 deletions crates/thalyx-cli/src/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ pub enum DevCommand {
/// Show what a bundle contains, without installing it
Inspect { bundle: PathBuf },

/// Look at a staged Rust runtime and say whether a machine could use it
///
/// `vault/09-Notas-Tecnicas/Runtime-Rust-Agente.md`. Two questions, and
/// they are not the same one: whether the artifact holds what it must and
/// nothing it must not, and whether every library its own programs name is
/// *inside it*. The second is the one `ldd` cannot answer, because `ldd`
/// asks the host — and on the host that built it, an artifact that would
/// be broken inside the machine looks perfect.
RustRuntime {
/// The artifact directory
artifact: PathBuf,
},

/// Build the machine's root filesystem, or count what is in one
///
/// The image is the Linux kernel and one program. This is what makes the
Expand Down Expand Up @@ -125,6 +138,7 @@ pub fn run(command: DevCommand) -> Fallible {
out,
} => pack(&source, &manifest, &key, &out),
DevCommand::Inspect { bundle } => inspect(&bundle),
DevCommand::RustRuntime { artifact } => rust_runtime(&artifact),
DevCommand::Image { binary, out, list } => match (binary, out, list) {
(_, _, Some(archive)) => crate::image::list(&archive),
(Some(binary), Some(out), None) => crate::image::build(&binary, &out),
Expand Down Expand Up @@ -528,3 +542,98 @@ fn append_bytes<W: Write>(
builder.append_data(&mut header, name, contents)?;
Ok(())
}

/// Say whether a staged Rust runtime is one.
///
/// Printed as facts and not as a verdict word alone: a staging step that says
/// only `FAILED` sends whoever ran it to read this source, and the thing they
/// need is which file is missing.
fn rust_runtime(artifact: &Path) -> Fallible {
use thalyx_rust::runtime;

println!(" {}", artifact.display());
let report = runtime::inspect(artifact);
let closure = runtime::closure(artifact);

if let Some(runtime) = runtime::read(artifact) {
println!(" is {}", runtime.describe());
}
println!(
" size {} in {} program(s)",
directory_size(artifact),
closure.programs.len()
);
for interpreter in &closure.interpreters {
println!(" loader {interpreter}");
}
for asked in &closure.programs {
let name = asked
.program
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let named: Vec<String> = asked
.libraries
.iter()
.map(|(library, here)| format!("{library}{}", if *here { "" } else { " ← MISSING" }))
.collect();
println!(" {name:<32}{}", named.join(", "));
}

let mut wrong = Vec::new();
for missing in &report.missing {
wrong.push(format!("{missing} is not in the artifact"));
}
for forbidden in &report.forbidden {
wrong.push(format!(
"{forbidden} is in the artifact — a whole toolchain was copied, not a runtime assembled"
));
}
for target in &report.other_targets {
wrong.push(format!(
"lib/rustlib/{target} is a standard library for a machine this is not"
));
}
for (program, library) in &closure.unresolved {
wrong.push(format!(
"{program} asks for {library} and the artifact does not carry it, so it would \
resolve against whatever the machine running it happens to have"
));
}
if !closure.interpreter_inside {
wrong.push(
"the artifact does not carry the loader its own programs ask the kernel for"
.to_string(),
);
}

if wrong.is_empty() {
println!();
println!(" closed: every library these programs name is inside the artifact.");
return Ok(());
}
println!();
for line in &wrong {
println!(" no {line}");
}
Err(format!("{} is not a usable Rust runtime", artifact.display()).into())
}

/// How big a directory is, for a line a human reads.
fn directory_size(root: &Path) -> String {
fn walk(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| match entry.file_type() {
Ok(kind) if kind.is_dir() => walk(&entry.path()),
Ok(kind) if kind.is_file() => entry.metadata().map(|m| m.len()).unwrap_or(0),
_ => 0,
})
.sum()
}
let bytes = walk(root);
format!("{} MB", bytes / 1_000_000)
}
2 changes: 1 addition & 1 deletion crates/thalyx-cli/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1661,7 +1661,7 @@ fn rust_check(
// change not compiling.
let environment: Vec<(String, String)> = thalyx_rust::toolchain::environment()
.into_iter()
.map(|(name, path)| (name.to_string(), path.display().to_string()))
.map(|(name, value)| (name.to_string(), value))
.collect();
let outcome = run_confined(
asked,
Expand Down
8 changes: 8 additions & 0 deletions crates/thalyx-cli/src/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ pub const EXPOSED: &[Exposed] = &[
repeating: MORE_OPTIONS,
verbatim_from: QUOTED,
},
// Read-only, takes nothing, and it is what a harness asks before it spends
// money on a machine that may not have a compiler on it.
Exposed {
verb: "toolchain",
slots: &[],
repeating: NOTHING_MORE,
verbatim_from: QUOTED,
},
Exposed {
verb: "rename",
// What to rename and what to call it. Both text: the first may be a
Expand Down
42 changes: 42 additions & 0 deletions crates/thalyx-cli/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,48 @@ mod tests {
use super::*;
use std::io::Write;

#[test]
fn the_rust_runtime_is_not_in_the_image() {
// `Construccion-del-ISO.md`: the image is the Linux kernel and one
// program, and the decree is written to be *countable* rather than
// argued. On 2026-08-31 six hundred megabytes of Rust toolchain
// arrived for the agent to program with, and the one way that could
// have gone wrong quietly was for it to be put in here — where it
// would double the boot's memory before the machine said a word, and
// where `make count` would stop saying what it says.
//
// It goes on the store, like the engine and the model, because that is
// the difference between what Thalyx *is* and what has been installed
// on it. This asserts the archive's own list, which is the same list
// the builder writes.
for directory in DIRECTORIES {
assert!(
!directory.contains("toolchain") && !directory.contains("rust"),
"the image's directory list has grown a {directory}"
);
}
let held = tempfile::tempdir().expect("a temp dir");
let binary = fake_binary(held.path());
let archive = held.path().join("initramfs.cpio");
build(&binary, &archive).expect("building the archive");
let bytes = std::fs::read(&archive).expect("reading the archive");
let entries = parse(&bytes).expect("parsing the archive");
assert_eq!(
entries.iter().filter(|entry| entry.is_program()).count(),
1,
"the image is the kernel and one program"
);
for entry in &entries {
assert!(
!entry.name.contains("cargo")
&& !entry.name.contains("rust")
&& !entry.name.contains("toolchain"),
"{} is in the image and belongs on the store",
entry.name
);
}
}

fn fake_binary(dir: &Path) -> std::path::PathBuf {
let path = dir.join("thalyx");
let mut file = std::fs::File::create(&path).unwrap();
Expand Down
4 changes: 4 additions & 0 deletions crates/thalyx-cli/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@ pub fn run() -> Fallible {
// unmounted /opt/thalyx would report an empty machine rather than an
// unmounted one.
crate::store_disk::mount().report();
// After the store, because the runtime it points at is on the store, and
// before the session, because the session is what answers `context` and
// `rename`.
crate::store_disk::link_runtime_loader();

match attach_lsm() {
Ok(detail) => println!(" ok thalyx-lsm {detail}"),
Expand Down
1 change: 1 addition & 0 deletions crates/thalyx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ mod session;
mod snapshot;
mod store_disk;
mod term;
mod toolchain;
mod words;

use clap::{Parser, Subcommand};
Expand Down
2 changes: 1 addition & 1 deletion crates/thalyx-cli/src/semantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ fn provider_for(store_root: &Path, tree: &Path) -> Provider {
thalyx_rust::toolchain::readable(),
thalyx_rust::toolchain::environment()
.into_iter()
.map(|(name, path)| (name.to_string(), path.display().to_string()))
.map(|(name, value)| (name.to_string(), value))
.collect(),
);
match Store::open(store_root) {
Expand Down
8 changes: 7 additions & 1 deletion crates/thalyx-cli/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1480,7 +1480,7 @@ pub fn run(store: &Store, once: bool) -> Fallible {
println!(" `buscar <nombre>`, `encontrar <patrón>`, `contenido <texto>`,");
println!(" `historia`, `intento`, `cambios`,");
println!(" `contexto <nombre|archivo> [presupuesto=N|usos=N|expandir=asa]`,");
println!(" `renombrar-simbolo <nombre> <nuevo>`,");
println!(" `renombrar-simbolo <nombre> <nuevo>`, `herramientas`,");
println!(" `hacer <programa>`, `evidencia <id>`,");
println!(" `procesos [patrón]`, `memoria`, `matar <pid> [forzar]`,");
println!(" `disponibles`, `instalar <id>`, `modulos`, `correr <id>`,");
Expand Down Expand Up @@ -2153,6 +2153,12 @@ fn dispatch_asking(
"contexto" | "context" => {
crate::semantic::context(store.root(), here, "", face)?;
}
// What the machine can resolve *with*, which is a different question
// from what a name is, and the one nobody asked before paying for a
// run on a machine with no compiler.
"herramientas" | "toolchain" => {
crate::toolchain::act(here, face)?;
}
// `renombrar-simbolo` and not `renombrar`: `renombrar` has meant
// *move this file* since the file verbs existed, and stealing the word
// would silently change what an existing caller's line does. Two things
Expand Down
56 changes: 56 additions & 0 deletions crates/thalyx-cli/src/store_disk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,62 @@ pub fn mount() -> Store {
}
}

/// Make the name the Rust runtime's own binaries were compiled with resolve.
///
/// ## Why PID 1 does this and why it is a symlink
///
/// Every program in `toolchains/rust/<identity>/` carries
/// `PT_INTERP: /lib/ld-musl-x86_64.so.1` in its ELF header. That is not a
/// choice anybody here made — it is what the upstream artifact was linked
/// with, and the *kernel* reads it, before the process exists, as an absolute
/// path. A machine without that name answers `execve` with `ENOENT`, and what
/// the human sees is "there is no cargo on this machine" about a cargo that is
/// right there. That sentence, from inside a benchmark run on 2026-08-30, is
/// what this whole change exists to stop.
///
/// A symlink and not a bind mount, for a reason that would only show up later:
/// a confined program's root filesystem binds `/lib`, and while that bind is
/// recursive today, a symlink is a directory entry in the initramfs root and
/// travels with **any** bind, recursive or not. The cheaper thing is also the
/// one a change somewhere else cannot quietly break.
///
/// Nothing happens on a machine with no runtime staged, which is every machine
/// that is not preparing an agent. Reported either way: a store that carries a
/// compiler and a `/lib` that does not point at it is exactly the failure
/// above, and it must not be something a person has to go looking for.
pub fn link_runtime_loader() {
let staged = thalyx_rust::runtime::staged(Path::new(thalyx_rust::runtime::STORE_ROOT));
let Some(runtime) = staged.into_iter().next() else {
return;
};
let name = Path::new(thalyx_rust::runtime::LOADER);
if let Some(parent) = name.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Removed first: a previous boot may have left one pointing at a runtime
// that is no longer the one on the disk, and a stale loader is one from
// another Rust release — which fails at a relocation deep inside the first
// analysis rather than at the link, where it would be readable.
let _ = std::fs::remove_file(name);
match std::os::unix::fs::symlink(runtime.loader(), name) {
Ok(()) => println!(
" ok rust {} — {} points at it",
runtime.describe(),
name.display()
),
Err(error) => {
println!(
" no rust {} is staged and unusable",
runtime.identity
);
println!(" {} could not be made: {error}", name.display());
println!(" Every program in it asks the kernel for that name, so cargo");
println!(" and rust-analyzer will not start, and the machine will say");
println!(" there is no cargo on it.");
}
}
}

impl Store {
/// Print what happened, in the shape the rest of the boot uses.
///
Expand Down
Loading
Loading