fix(daemon): verify tenant provision targets (Sam & Pi) #91

Merged
clawdie merged 1 commit from fix/tenant-provision-targets into main 2026-06-20 06:19:28 +02:00
2 changed files with 97 additions and 13 deletions

View file

@ -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, /// Post-spawn hook: if a jailed agent has a matching tenant record,
/// call colibri-vault to provision the jail's .env before agent startup. /// call colibri-vault to provision the jail's .env before agent startup.
pub(crate) async fn provision_tenant_env( pub(crate) async fn provision_tenant_env(
@ -325,6 +334,18 @@ pub(crate) async fn provision_tenant_env(
None => return, 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!( info!(
jail = jail_name, jail = jail_name,
collection = tenant.collection_id, collection = tenant.collection_id,
@ -462,6 +483,15 @@ async fn scheduler_tick_fn(state: &SharedState) {
mod tests { mod tests {
use super::*; 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] #[test]
fn test_daemon_loop_config_defaults() { fn test_daemon_loop_config_defaults() {
let c = DaemonLoopConfig::default(); let c = DaemonLoopConfig::default();

View file

@ -19,14 +19,32 @@ use tokio::select;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use crate::spawner::{AgentSpawnConfig, JailConfig, Provider, Spawner};
use crate::daemon::provision_tenant_env; use crate::daemon::provision_tenant_env;
use crate::spawner::{AgentSpawnConfig, JailConfig, Provider, Spawner};
use crate::{ColibriCommand, ColibriResponse, SharedState}; use crate::{ColibriCommand, ColibriResponse, SharedState};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Socket server // 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 /// Prepare `socket_path` for `bind()`: remove a stale socket file, but never one
/// a live daemon is still serving. /// a live daemon is still serving.
/// ///
@ -444,13 +462,10 @@ async fn cmd_spawn_agent(
} }
} }
// Extract jail info before spawn consumes agent_config // Extract jail info before spawn consumes agent_config.
let jail_for_provision = agent_config.jail.as_ref().and_then(|j| { // Prefer the explicit jail name; only parse the Bastille root path as a
j.root_path.as_ref().map(|root| { // fallback. The path shape is /usr/local/bastille/jails/<name>/root.
let name = root.split('/').nth(4).unwrap_or("unknown").to_string(); let jail_for_provision = agent_config.jail.as_ref().and_then(jail_provision_target);
(name, root.clone())
})
});
// Merge extra env into agent config // Merge extra env into agent config
let mut agent_config = agent_config; let mut agent_config = agent_config;
@ -488,11 +503,7 @@ async fn cmd_spawn_agent(
let root = root.clone(); let root = root.clone();
let state_clone = state.clone(); let state_clone = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
provision_tenant_env( provision_tenant_env(&state_clone, &jail_name, &root).await;
&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] #[test]
fn glasspane_snapshot_command_deserializes() { fn glasspane_snapshot_command_deserializes() {
let cmd: ColibriCommand = serde_json::from_str(r#"{"cmd":"glasspane-snapshot"}"#).unwrap(); let cmd: ColibriCommand = serde_json::from_str(r#"{"cmd":"glasspane-snapshot"}"#).unwrap();