diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index dbd4c39..f0b2af9 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -298,6 +298,15 @@ async fn memory_handoff(state: &SharedState) { ); } +fn trim_trailing_slash(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() + } +} + /// Post-spawn hook: if a jailed agent has a matching tenant record, /// call colibri-vault to provision the jail's .env before agent startup. pub(crate) async fn provision_tenant_env( @@ -325,6 +334,18 @@ pub(crate) async fn provision_tenant_env( None => return, }; + let stored_root = trim_trailing_slash(&tenant.jail_root_path); + let spawned_root = trim_trailing_slash(jail_root_path); + if stored_root != spawned_root { + warn!( + jail = jail_name, + tenant_root = %tenant.jail_root_path, + spawned_root = %jail_root_path, + "tenant root path mismatch — refusing vault provision" + ); + return; + } + info!( jail = jail_name, collection = tenant.collection_id, @@ -462,6 +483,15 @@ async fn scheduler_tick_fn(state: &SharedState) { mod tests { use super::*; + #[test] + fn trim_trailing_slash_handles_root_paths() { + assert_eq!( + trim_trailing_slash("/usr/local/bastille/jails/pi0/root/"), + "/usr/local/bastille/jails/pi0/root" + ); + assert_eq!(trim_trailing_slash("/"), "/"); + } + #[test] fn test_daemon_loop_config_defaults() { let c = DaemonLoopConfig::default(); diff --git a/crates/colibri-daemon/src/socket.rs b/crates/colibri-daemon/src/socket.rs index a5365aa..7e9d6d4 100644 --- a/crates/colibri-daemon/src/socket.rs +++ b/crates/colibri-daemon/src/socket.rs @@ -19,14 +19,32 @@ use tokio::select; use tokio::sync::broadcast; use tracing::{debug, error, info, trace, warn}; -use crate::spawner::{AgentSpawnConfig, JailConfig, Provider, Spawner}; use crate::daemon::provision_tenant_env; +use crate::spawner::{AgentSpawnConfig, JailConfig, Provider, Spawner}; use crate::{ColibriCommand, ColibriResponse, SharedState}; // --------------------------------------------------------------------------- // Socket server // --------------------------------------------------------------------------- +fn jail_provision_target(jail: &JailConfig) -> Option<(String, String)> { + let root = jail.root_path.as_deref().or(jail.path.as_deref())?; + let name = jail + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .or_else(|| bastille_jail_name_from_root(root))?; + Some((name.to_string(), root.to_string())) +} + +fn bastille_jail_name_from_root(root: &str) -> Option<&str> { + let parts: Vec<&str> = root.split('/').filter(|part| !part.is_empty()).collect(); + parts.windows(3).find_map(|window| match window { + ["jails", name, "root"] if !name.is_empty() => Some(*name), + _ => None, + }) +} + /// Prepare `socket_path` for `bind()`: remove a stale socket file, but never one /// a live daemon is still serving. /// @@ -444,13 +462,10 @@ async fn cmd_spawn_agent( } } - // Extract jail info before spawn consumes agent_config - let jail_for_provision = agent_config.jail.as_ref().and_then(|j| { - j.root_path.as_ref().map(|root| { - let name = root.split('/').nth(4).unwrap_or("unknown").to_string(); - (name, root.clone()) - }) - }); + // Extract jail info before spawn consumes agent_config. + // Prefer the explicit jail name; only parse the Bastille root path as a + // fallback. The path shape is /usr/local/bastille/jails//root. + let jail_for_provision = agent_config.jail.as_ref().and_then(jail_provision_target); // Merge extra env into agent config let mut agent_config = agent_config; @@ -488,11 +503,7 @@ async fn cmd_spawn_agent( let root = root.clone(); let state_clone = state.clone(); tokio::spawn(async move { - provision_tenant_env( - &state_clone, - &jail_name, - &root, - ).await; + provision_tenant_env(&state_clone, &jail_name, &root).await; }); } } @@ -788,6 +799,49 @@ mod tests { } } + #[test] + fn jail_provision_target_prefers_explicit_name() { + let jail = JailConfig { + name: Some("tenant-a".to_string()), + root_path: Some("/usr/local/bastille/jails/tenant-a/root".to_string()), + ..Default::default() + }; + + assert_eq!( + jail_provision_target(&jail), + Some(( + "tenant-a".to_string(), + "/usr/local/bastille/jails/tenant-a/root".to_string() + )) + ); + } + + #[test] + fn jail_provision_target_parses_bastille_root_fallback() { + let jail = JailConfig { + root_path: Some("/usr/local/bastille/jails/pi0/root".to_string()), + ..Default::default() + }; + + assert_eq!( + jail_provision_target(&jail), + Some(( + "pi0".to_string(), + "/usr/local/bastille/jails/pi0/root".to_string() + )) + ); + } + + #[test] + fn jail_provision_target_rejects_unparseable_unnamed_root() { + let jail = JailConfig { + root_path: Some("/tmp/not-a-bastille-root".to_string()), + ..Default::default() + }; + + assert_eq!(jail_provision_target(&jail), None); + } + #[test] fn glasspane_snapshot_command_deserializes() { let cmd: ColibriCommand = serde_json::from_str(r#"{"cmd":"glasspane-snapshot"}"#).unwrap();