//! Bus de agente: `/run/agent.sock`. Ver `docs/07-agent-bus.md`. //! //! Modelo de threading (síncrono, una conexión = N threads pequeños): //! //! ```text //! [accept loop] ── spawn ──▶ conn{ //! [reader thread] ── dispatch ──┐ //! [bus forwarder] ─────────────┼──▶ tx ──▶ [writer thread] ──▶ socket //! [compile worker] (puntual) ───┘ //! } //! ``` //! //! No usamos `tokio` para no arrastrar runtime asíncrono: la cadencia esperada del bus es //! humana + agente (decenas de mensajes/seg en el peor caso). Cada conexión tiene su propio //! canal `Sender`; la `EventBus` global re-emite Modified/Crashed a esos canales. use std::io::{BufRead, BufReader, Write}; use std::os::fd::AsFd; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Sender}; use std::sync::{Arc, Mutex}; use std::thread; use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; use crate::events::EventBus; use takana_core::proto::{ Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, RecipeInline, PROTOCOL_VERSION, }; /// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué /// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`; /// otros UIDs ⇒ sólo `query`. La policy más expresiva (lectura de un toml en `/etc/hammer`) /// es trabajo posterior; la firma del callable basta para encajarla sin tocar este módulo. pub type CapsPolicy = Arc Vec + Send + Sync>; pub fn default_policy() -> CapsPolicy { let my_uid = unsafe { libc::getuid() }; Arc::new(move |peer: &Peer| { if peer.uid == my_uid { vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init] } else { vec![Cap::Query] } }) } /// Política derivada de un fichero `agent-caps.toml` (SDD 07 §4). El peer no elige sus caps; /// las resuelve la config por `(uid, gid)`. pub fn policy_from_config(cfg: takana_core::AgentCapsConfig) -> CapsPolicy { Arc::new(move |peer: &Peer| cfg.caps_for(peer.uid, peer.gid)) } /// Contexto que necesita el dispatcher para resolver comandos. Compartido por todas las /// conexiones (clones baratos: paths + Arc). #[derive(Clone)] pub struct BusContext { pub store_root: PathBuf, pub init_control: PathBuf, pub events: EventBus, } #[derive(Debug, thiserror::Error)] pub enum Error { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("bind {0}: {1}")] Bind(PathBuf, std::io::Error), #[error("peer creds: {0}")] Peer(String), } pub type Result = std::result::Result; /// Arranca el listener bloqueante. Pensado para correr en su propio thread desde `main`. pub fn serve_agent_bus( sock_path: &Path, policy: CapsPolicy, ctx: BusContext, ) -> Result<()> { // Limpia un socket olvidado de una ejecución previa. Si el path está ocupado por algo // que no es un socket, dejamos que `bind` falle — no queremos rm-rf por accidente. let _ = std::fs::remove_file(sock_path); let listener = UnixListener::bind(sock_path) .map_err(|e| Error::Bind(sock_path.to_path_buf(), e))?; tracing::info!(sock = %sock_path.display(), "bus: escuchando"); for stream in listener.incoming() { match stream { Ok(s) => { let policy = policy.clone(); let ctx = ctx.clone(); thread::Builder::new() .name("bus-conn".into()) .spawn(move || { if let Err(e) = handle_connection(s, policy, ctx) { tracing::warn!(error = %e, "bus: conexión terminó con error"); } }) .ok(); } Err(e) => tracing::warn!(error = %e, "bus: accept falló"), } } Ok(()) } fn read_peer(stream: &UnixStream) -> Result { let cred = getsockopt(&stream.as_fd(), PeerCredentials) .map_err(|e| Error::Peer(e.to_string()))?; Ok(Peer { uid: cred.uid(), gid: cred.gid(), pid: cred.pid(), }) } fn handle_connection(stream: UnixStream, policy: CapsPolicy, ctx: BusContext) -> Result<()> { let peer = read_peer(&stream)?; let caps = policy(&peer); tracing::info!(?peer, ?caps, "bus: nueva conexión"); // Canal de eventos hacia el writer. Todos los productores (dispatcher, bus forwarder, // workers de COMPILE) clonan este Sender. let (tx, rx) = mpsc::channel::(); // Writer thread: serializa todo lo que recibe del canal y lo escribe al socket. let stream_writer = stream.try_clone()?; let _writer_handle = thread::Builder::new() .name("bus-conn-writer".into()) .spawn(move || writer_loop(stream_writer, rx)) .ok(); // Bus forwarder thread: cualquier evento global (Modified/Crashed) se re-emite a este // peer. Se cierra solo cuando el global drop el último Sender (en la práctica, nunca) // o cuando nuestro tx local cae (peer cerró). let bus_rx = ctx.events.subscribe(); let bus_tx = tx.clone(); thread::Builder::new() .name("bus-conn-fwd".into()) .spawn(move || { for ev in bus_rx { if bus_tx.send(ev).is_err() { break; } } }) .ok(); // Handshake. La primera línea debe ser Hello. let mut reader = BufReader::new(stream); let mut first = String::new(); reader.read_line(&mut first)?; match serde_json::from_str::(first.trim()) { Ok(Command::Hello { ver, client }) => { tracing::info!(ver, client = %client, "bus: hello"); let _ = tx.send(Event::Welcome { ver: PROTOCOL_VERSION, caps: caps.clone(), peer: peer.clone(), }); } Ok(other) => { let _ = tx.send(Event::Error { code: "no_hello".into(), msg: format!("se esperaba 'hello', llegó '{}'", t_of(&other)), }); return Ok(()); } Err(e) => { let _ = tx.send(Event::Error { code: "bad_handshake".into(), msg: format!("hello inválido: {e}"), }); return Ok(()); } } // Read loop. Cada línea es un Command. La conexión se cierra cuando se cierra EOF, o // cuando un error de IO lo fuerza. let caps_set: std::collections::BTreeSet = caps.iter().copied().collect(); let caps_set = Arc::new(caps_set); for line in reader.lines() { let line = match line { Ok(l) => l, Err(_) => break, }; if line.trim().is_empty() { continue; } let cmd: Command = match serde_json::from_str(&line) { Ok(c) => c, Err(e) => { let _ = tx.send(Event::Error { code: "bad_command".into(), msg: format!("JSON inválido: {e}"), }); continue; } }; dispatch(cmd, &tx, &caps_set, &ctx); } Ok(()) } fn writer_loop(mut stream: UnixStream, rx: mpsc::Receiver) { for ev in rx { let line = match serde_json::to_string(&ev) { Ok(s) => s, Err(e) => { tracing::error!(error = %e, "bus: serializando event"); continue; } }; if stream.write_all(line.as_bytes()).is_err() { break; } if stream.write_all(b"\n").is_err() { break; } // El bus es de baja cadencia; flush por mensaje no es relevante en costes y mejora // mucho la latencia percibida del cliente. let _ = stream.flush(); } } fn dispatch( cmd: Command, tx: &Sender, caps: &Arc>, ctx: &BusContext, ) { if let Some(needed) = Cap::required_for(&cmd) { if !caps.contains(&needed) { let _ = tx.send(Event::Error { code: "no_cap".into(), msg: format!("comando requiere capacidad '{needed:?}', no concedida"), }); return; } } match cmd { Command::Hello { .. } => { // Doble hello: lo ignoramos con un Error suave. let _ = tx.send(Event::Error { code: "duplicate_hello".into(), msg: "ya hicimos hello en esta conexión".into(), }); } Command::Compile { recipe } => { // Build pesado: a un worker. El read-loop sigue libre para más comandos. let tx = tx.clone(); let store_root = ctx.store_root.clone(); thread::Builder::new() .name("bus-compile".into()) .spawn(move || run_compile(recipe, store_root, tx)) .ok(); } Command::Inject { artifact, target, overlay: _ } => { let store = match takana_core::Store::open(&ctx.store_root) { Ok(s) => s, Err(e) => { let _ = tx.send(Event::Error { code: "store".into(), msg: e.to_string(), }); return; } }; let artifact_dir = match store.find_by_hash(&artifact) { Ok(p) => p, Err(e) => { let _ = tx.send(Event::Error { code: "not_found".into(), msg: e.to_string(), }); return; } }; let report = match takana_build::run_hydrate( &artifact_dir, Path::new(&target), takana_core::LinkMode::Static, None, ) { Ok(r) => r, Err(e) => { let _ = tx.send(Event::Error { code: "hydrate_failed".into(), msg: e.to_string(), }); return; } }; let _ = tx.send(Event::Injected { artifact, target, files: report.files.len(), }); } Command::Query { what, path, name, expr } => { let value = match what.as_str() { "file" => { let Some(p) = path else { let _ = tx.send(Event::Error { code: "bad_query".into(), msg: "query 'file' requiere 'path'".into(), }); return; }; query_file(&p) } "artifact" => { let Some(h) = name.as_deref().or(path.as_deref()) else { let _ = tx.send(Event::Error { code: "bad_query".into(), msg: "query 'artifact' requiere 'name' (el hash)".into(), }); return; }; query_artifact(&ctx.store_root, h) } "expr" => { let Some(e) = expr.as_deref() else { let _ = tx.send(Event::Error { code: "bad_query".into(), msg: "query 'expr' requiere 'expr' (la expresión)".into(), }); return; }; // El daemon evalúa con el `fs_root` del sistema host (None ⇒ rutas // absolutas tal y como vienen). Si el .swm necesitara otra base, el // cliente puede evaluar localmente con un EvalContext distinto. takana_core::query::eval_str(e, &takana_core::query::EvalContext::new()) } other => { let _ = tx.send(Event::Error { code: "unknown_query".into(), msg: format!("'what'='{other}' no soportado"), }); return; } }; let _ = tx.send(Event::QueryResult { what, value }); } Command::Init { cmd } => { match crate::control::send_to_fifo(&ctx.init_control, &cmd) { Ok(()) => { let _ = tx.send(Event::InitAck { cmd }); } Err(e) => { let _ = tx.send(Event::Error { code: "init_send_failed".into(), msg: e.to_string(), }); } } } } } fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { let name = recipe.name.clone(); let mutation = takana_core::swm::Mutation::SourcePatch { repo: recipe.repo, commit: recipe.commit, tarball: recipe.tarball, sha256: recipe.sha256, strip_components: None, // El proto del bus transporta UN parche inline (`RecipeInline.patch`); el orquestador // corta antes si el `.swm` traía varios (ver `Mutation::SourcePatch::patches`). patch: recipe.patch, patches: Vec::new(), patch_url: None, build: takana_core::swm::SwmBuild { compiler: recipe.compiler, target: recipe.target, link: recipe.link, flags: recipe.flags, phases: Default::default(), zig_version: None, strip_debug: None, }, target_bin: format!("/usr/bin/{name}"), expected_hash: None, deps: Default::default(), // [H1c] La evidencia declarada por el cliente viaja a la mutación para ejecutarse tras // sellar el artefacto (ver `evidence_verdict`). evidence: recipe.evidence, // [H4b] El camino del daemon (agente propone → construye) no declara superficies: el gate // de compatibilidad vive en `install` (CLI) contra el estado instalado. Vacío aquí. slots: Default::default(), }; let store = match takana_core::Store::open(&store_root) { Ok(s) => s, Err(e) => { let _ = tx.send(Event::BuildFailed { recipe: name, reason: format!("store: {e}"), log_tail: None, }); return; } }; let cfg = takana_build::BuildConfig::from_env_or_defaults(store.root()); match takana_build::build_source_patch(&mutation, &cfg, &store, None, None) { Ok(h) => { // [H1c] El artefacto está sellado; ahora EJECUTAMOS la evidencia declarada y // adjuntamos el veredicto. El gate ("no proponer" si falla) vive en el orquestador // —el lab sólo construye y reporta—; por eso enviamos `BuildReady` con veredicto // aunque algún check falle. let verdict = evidence_verdict(&mutation, &cfg, &store); let _ = tx.send(Event::BuildReady { recipe: name, artifact: h.as_str().to_string(), verdict, }); } Err(e) => { // Si el fallo trae la cola del log del lab, la separamos de la razón para que la // IA pueda leer el error real del compilador, no sólo el mensaje de Rust. let (reason, log_tail) = match takana_build::sandbox::BuildFailure::from_error(&e) { Some(bf) => (bf.reason.clone(), bf.log_tail.clone()), None => (e.to_string(), None), }; let _ = tx.send(Event::BuildFailed { recipe: name, reason, log_tail, }); } } } /// [H1c] Ejecuta la evidencia declarada de una mutación `source_patch` y la reduce al veredicto /// serializable del bus. Devuelve: /// - `None` si la receta no declara evidencia (nada que correr). /// - `Some(all_passed=true/false, …)` con el resultado por check. /// - `Some(all_passed=false, …)` sintético si la evidencia ni siquiera pudo ejecutarse (falta una /// build-dep, error de sandbox…): eso también debe bloquear la propuesta, no pasar en silencio. fn evidence_verdict( mutation: &takana_core::swm::Mutation, cfg: &takana_build::BuildConfig, store: &takana_core::Store, ) -> Option { let has_evidence = matches!( mutation, takana_core::swm::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty() ); if !has_evidence { return None; } match run_evidence_inner(mutation, cfg, store) { Ok(v) => Some(v), Err(e) => { tracing::warn!(error = %e, "evidence: no se pudo ejecutar; veredicto = fallo"); Some(EvidenceVerdict { all_passed: false, max_level: None, checks: vec![EvidenceCheckVerdict { kind: takana_core::EvidenceKind::CmdExit, cmd: "".into(), passed: false, detail: format!("la evidencia no pudo ejecutarse: {e}"), }], }) } } } fn run_evidence_inner( mutation: &takana_core::swm::Mutation, cfg: &takana_build::BuildConfig, store: &takana_core::Store, ) -> takana_core::Result { let catalog_dir = takana_build::swm_bridge::catalog_dir_for(cfg, None); std::fs::create_dir_all(&catalog_dir)?; let recipe = takana_build::swm_bridge::recipe_from_source_patch(mutation, None, &catalog_dir)?; let report = takana_build::run_evidence(&recipe, cfg, store)?; Ok(EvidenceVerdict { all_passed: report.all_passed(), max_level: report.max_level_passed(), checks: report .results .iter() .map(|r| EvidenceCheckVerdict { kind: r.kind, cmd: r.cmd.clone(), passed: r.outcome.passed, detail: r.outcome.detail.clone(), }) .collect(), }) } fn query_file(path: &str) -> serde_json::Value { let p = Path::new(path); let meta = match std::fs::symlink_metadata(p) { Ok(m) => m, Err(e) => return serde_json::json!({"exists": false, "error": e.to_string()}), }; use std::os::unix::fs::MetadataExt; serde_json::json!({ "exists": true, "size": meta.size(), "mode": meta.mode(), "uid": meta.uid(), "gid": meta.gid(), "is_dir": meta.is_dir(), "is_symlink": meta.file_type().is_symlink(), }) } fn query_artifact(store_root: &Path, hash: &str) -> serde_json::Value { let store = match takana_core::Store::open(store_root) { Ok(s) => s, Err(e) => return serde_json::json!({"error": e.to_string()}), }; match store.find_by_hash(hash) { Ok(p) => serde_json::json!({"exists": true, "path": p.display().to_string()}), Err(_) => serde_json::json!({"exists": false}), } } fn t_of(cmd: &Command) -> &'static str { match cmd { Command::Hello { .. } => "hello", Command::Compile { .. } => "compile", Command::Inject { .. } => "inject", Command::Query { .. } => "query", Command::Init { .. } => "init", } } // Re-export para que el caller no tenga que importar Mutex/Arc directamente. #[allow(dead_code)] pub(crate) type SharedCaps = Arc>>; #[cfg(test)] mod tests { use super::*; #[test] fn default_policy_grants_query_to_others() { let p = default_policy(); // Un peer con UID distinto al actual debe quedar como sólo-query. let foreign = Peer { uid: u32::MAX, gid: 0, pid: 1, }; assert_eq!(p(&foreign), vec![Cap::Query]); } #[test] fn config_policy_resolves_by_uid_gid() { let cfg = takana_core::AgentCapsConfig::from_toml( r#" default = ["query"] [[rule]] uid = 0 caps = ["query", "compile", "inject", "inject-real", "init"] "#, ) .unwrap(); let p = policy_from_config(cfg); let root = Peer { uid: 0, gid: 0, pid: 1 }; let other = Peer { uid: 1234, gid: 1234, pid: 2 }; assert!(p(&root).contains(&Cap::InjectReal)); assert_eq!(p(&other), vec![Cap::Query]); } #[test] fn t_of_matches_all_commands() { // No es exhaustivo de Cargo, pero verifica que no nos olvidemos del nombre del tag. assert_eq!(t_of(&Command::Hello { ver: 1, client: "".into() }), "hello"); assert_eq!( t_of(&Command::Compile { recipe: RecipeInline { name: "x".into(), repo: Some("g".into()), commit: Some("c".into()), tarball: None, sha256: None, patch: None, compiler: "zig-cc".into(), target: "t".into(), link: "static".into(), flags: vec![], evidence: Default::default(), }, }), "compile" ); } #[test] fn query_file_reports_missing() { let v = query_file("/no/existe/seguramente/ahora"); assert_eq!(v["exists"], serde_json::json!(false)); } }