diff --git a/crates/colibri-client/src/bin/colibri.rs b/crates/colibri-client/src/bin/colibri.rs index 345404a..237d647 100644 --- a/crates/colibri-client/src/bin/colibri.rs +++ b/crates/colibri-client/src/bin/colibri.rs @@ -20,6 +20,7 @@ enum Command { model: String, session_id: Option, system_prompt: Option, + jail: Option, }, KillAgent { agent_id: String, @@ -52,6 +53,12 @@ enum Command { name: String, capabilities: Vec, }, + RegisterTenant { + tenant_id: String, + jail_root_path: String, + collection_id: String, + }, + ListTenants, ListAgents, } @@ -64,8 +71,8 @@ fn usage() -> &'static str { colibri [--socket PATH] status colibri [--socket PATH] snapshot colibri [--socket PATH] list-sessions - colibri [--socket PATH] spawn-local EXECUTABLE [--session-id ID] [--system-prompt TEXT] - colibri [--socket PATH] spawn-agent PROVIDER MODEL [--session-id ID] [--system-prompt TEXT] + colibri [--socket PATH] spawn-local EXECUTABLE [--session-id ID] [--system-prompt TEXT] [--jail-name NAME [--jail-root PATH]] + colibri [--socket PATH] spawn-agent PROVIDER MODEL [--session-id ID] [--system-prompt TEXT] [--jail-name NAME [--jail-root PATH]] colibri [--socket PATH] kill AGENT_ID colibri [--socket PATH] get-session SESSION_ID colibri [--socket PATH] compact-session SESSION_ID @@ -74,6 +81,8 @@ fn usage() -> &'static str { colibri [--socket PATH] intake-task --title TEXT [--description TEXT] [--capability CAP]... colibri [--socket PATH] list-skills colibri [--socket PATH] register-skill NAME [--description TEXT] [--category CAT] + colibri [--socket PATH] register-tenant TENANT_ID JAIL_ROOT_PATH COLLECTION_ID + colibri [--socket PATH] list-tenants Socket defaults to COLIBRI_DAEMON_SOCKET, then the daemon's configured default. @@ -87,7 +96,11 @@ Examples: colibri register-skill freebsd-check --description "Live USB startup check" --category freebsd colibri list-skills colibri register-agent NAME [--capability CAP]... [--capabilities CSV] + colibri register-tenant proof0 /usr/local/bastille/jails/proof0/root proof0 + colibri list-tenants colibri list-agents + # jailed spawn triggers vault provisioning into the jail root + colibri spawn-agent local /usr/local/bin/colibri-test-agent --jail-name proof0 --jail-root /usr/local/bastille/jails/proof0/root "# } @@ -128,12 +141,17 @@ where if args.len() < 2 { Err("spawn-local requires EXECUTABLE\n\n".to_string() + usage()) } else { - let (session_id, system_prompt) = parse_spawn_options(&args[2..])?; + let SpawnOptions { + session_id, + system_prompt, + jail, + } = parse_spawn_options(&args[2..])?; Ok(Command::SpawnAgent { provider: "local".to_string(), model: args[1].clone(), session_id, system_prompt, + jail, }) } } @@ -141,12 +159,17 @@ where if args.len() < 3 { Err("spawn-agent requires PROVIDER MODEL\n\n".to_string() + usage()) } else { - let (session_id, system_prompt) = parse_spawn_options(&args[3..])?; + let SpawnOptions { + session_id, + system_prompt, + jail, + } = parse_spawn_options(&args[3..])?; Ok(Command::SpawnAgent { provider: args[1].clone(), model: args[2].clone(), session_id, system_prompt, + jail, }) } } @@ -197,6 +220,22 @@ where }) } } + "register-tenant" => { + if args.len() != 4 { + Err( + "register-tenant requires TENANT_ID JAIL_ROOT_PATH COLLECTION_ID\n\n" + .to_string() + + usage(), + ) + } else { + Ok(Command::RegisterTenant { + tenant_id: args[1].clone(), + jail_root_path: args[2].clone(), + collection_id: args[3].clone(), + }) + } + } + "list-tenants" | "tenants" => expect_arity(&args, 1).map(|()| Command::ListTenants), other => Err(format!("unknown command: {other}\n\n{}", usage())), }?; @@ -321,9 +360,11 @@ fn parse_intake_task_options( Ok((title, description, capabilities)) } -fn parse_spawn_options(args: &[String]) -> Result<(Option, Option), String> { +fn parse_spawn_options(args: &[String]) -> Result { let mut session_id = None; let mut system_prompt = None; + let mut jail_name = None; + let mut jail_root = None; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -341,10 +382,49 @@ fn parse_spawn_options(args: &[String]) -> Result<(Option, Option { + let Some(value) = args.get(i + 1) else { + return Err("--jail-name requires NAME\n\n".to_string() + usage()); + }; + jail_name = Some(value.clone()); + i += 2; + } + "--jail-root" => { + let Some(value) = args.get(i + 1) else { + return Err("--jail-root requires PATH\n\n".to_string() + usage()); + }; + jail_root = Some(value.clone()); + i += 2; + } other => return Err(format!("unknown spawn option: {other}\n\n{}", usage())), } } - Ok((session_id, system_prompt)) + let jail = if jail_name.is_some() || jail_root.is_some() { + Some(colibri_client::JailConfig { + name: jail_name, + root_path: jail_root, + ..Default::default() + }) + } else { + None + }; + Ok(SpawnOptions { + session_id, + system_prompt, + jail, + }) +} + +/// Parsed `spawn-agent` / `spawn-local` options (see [`parse_spawn_options`]). +struct SpawnOptions { + session_id: Option, + system_prompt: Option, + jail: Option, } fn parse_capabilities(args: &[String]) -> Result, String> { @@ -363,10 +443,21 @@ fn parse_capabilities(args: &[String]) -> Result, String> { let Some(value) = args.get(i + 1) else { return Err("--capabilities requires CSV\n\n".to_string() + usage()); }; - caps.extend(value.split(',').map(str::trim).filter(|c| !c.is_empty()).map(ToString::to_string)); + caps.extend( + value + .split(',') + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(ToString::to_string), + ); i += 2; } - other => return Err(format!("unknown register-agent option: {other}\n\n{}", usage())), + other => { + return Err(format!( + "unknown register-agent option: {other}\n\n{}", + usage() + )) + } } } Ok(caps) @@ -414,9 +505,10 @@ async fn run(options: Options) -> Result<(), ClientError> { model, session_id, system_prompt, + jail, } => print_json( &client - .spawn_agent(provider, model, session_id, system_prompt) + .spawn_agent_with(provider, model, session_id, system_prompt, jail) .await?, ), Command::KillAgent { agent_id } => print_json(&client.kill_agent(agent_id).await?), @@ -442,6 +534,16 @@ async fn run(options: Options) -> Result<(), ClientError> { Command::RegisterAgent { name, capabilities } => { print_json(&client.register_agent(name, capabilities).await?) } + Command::RegisterTenant { + tenant_id, + jail_root_path, + collection_id, + } => print_json( + &client + .register_tenant(tenant_id, jail_root_path, collection_id) + .await?, + ), + Command::ListTenants => print_json(&client.list_tenants().await?), Command::ListAgents => print_json(&client.list_agents().await?), } } @@ -511,6 +613,7 @@ mod tests { model: "/tmp/fake-agent".to_string(), session_id: Some("s1".to_string()), system_prompt: Some("hello".to_string()), + jail: None, }, } ); @@ -579,4 +682,65 @@ mod tests { let err = parse_args(["bogus"]).unwrap_err(); assert!(err.contains("unknown command")); } + + #[test] + fn parses_spawn_local_with_jail() { + assert_eq!( + parsed(&[ + "spawn-local", + "/tmp/fake-agent", + "--jail-name", + "proof0", + "--jail-root", + "/usr/local/bastille/jails/proof0/root", + ]), + Options { + socket_path: default_socket_path(), + command: Command::SpawnAgent { + provider: "local".to_string(), + model: "/tmp/fake-agent".to_string(), + session_id: None, + system_prompt: None, + jail: Some(colibri_client::JailConfig { + name: Some("proof0".to_string()), + root_path: Some("/usr/local/bastille/jails/proof0/root".to_string()), + ..Default::default() + }), + }, + } + ); + } + + #[test] + fn parses_tenant_commands() { + assert_eq!( + parsed(&[ + "register-tenant", + "proof0", + "/usr/local/bastille/jails/proof0/root", + "proof0", + ]), + Options { + socket_path: default_socket_path(), + command: Command::RegisterTenant { + tenant_id: "proof0".to_string(), + jail_root_path: "/usr/local/bastille/jails/proof0/root".to_string(), + collection_id: "proof0".to_string(), + }, + } + ); + assert_eq!( + parsed(&["list-tenants"]), + Options { + socket_path: default_socket_path(), + command: Command::ListTenants, + } + ); + } + + #[test] + fn rejects_register_tenant_wrong_arity() { + let err = parse_args(["register-tenant", "only-one"]).unwrap_err(); + assert!(err.contains("register-tenant requires")); + } } diff --git a/crates/colibri-client/src/lib.rs b/crates/colibri-client/src/lib.rs index aca5176..a1ec575 100644 --- a/crates/colibri-client/src/lib.rs +++ b/crates/colibri-client/src/lib.rs @@ -9,6 +9,10 @@ use std::path::{Path, PathBuf}; use colibri_daemon::{ColibriCommand, ColibriResponse}; use colibri_glasspane::GlasspaneSnapshot; + +/// Re-export so CLI/callers can build a [`JailConfig`] for jailed spawns without +/// adding a `colibri-daemon` dependency just for the type. +pub use colibri_daemon::spawner::JailConfig; use serde::de::DeserializeOwned; use thiserror::Error; use tokio::{ @@ -102,6 +106,21 @@ impl DaemonClient { model: impl Into, session_id: Option, system_prompt: Option, + ) -> Result { + self.spawn_agent_with(provider, model, session_id, system_prompt, None) + .await + } + + /// Like [`spawn_agent`](Self::spawn_agent) but with optional FreeBSD jail + /// confinement. Setting `jail` is the only way to trigger vault provisioning + /// (`provision_tenant_env`) after spawn — the no-jail path skips it. + pub async fn spawn_agent_with( + &self, + provider: impl Into, + model: impl Into, + session_id: Option, + system_prompt: Option, + jail: Option, ) -> Result { self.request(&ColibriCommand::SpawnAgent { provider: provider.into(), @@ -109,7 +128,7 @@ impl DaemonClient { session_id, system_prompt, local_args: None, - jail: None, + jail, }) .await } @@ -197,6 +216,28 @@ impl DaemonClient { self.request(&ColibriCommand::ListAgents).await } + /// Register a vault tenant — the 1:1:1 map (tenant_id = jail name = + /// Vaultwarden collection) used by `colibri-vault` to provision `.env` after + /// jail creation. Retires the raw-SQLite insert in the first-proof runbook. + pub async fn register_tenant( + &self, + tenant_id: impl Into, + jail_root_path: impl Into, + collection_id: impl Into, + ) -> Result { + self.request(&ColibriCommand::RegisterTenant { + tenant_id: tenant_id.into(), + jail_root_path: jail_root_path.into(), + collection_id: collection_id.into(), + }) + .await + } + + /// List all registered tenants (status verification without raw SQLite). + pub async fn list_tenants(&self) -> Result { + self.request(&ColibriCommand::ListTenants).await + } + pub async fn register_skill( &self, name: impl Into, diff --git a/crates/colibri-client/tests/live_socket_check.rs b/crates/colibri-client/tests/live_socket_check.rs index c2c9fd6..dc99dcc 100644 --- a/crates/colibri-client/tests/live_socket_check.rs +++ b/crates/colibri-client/tests/live_socket_check.rs @@ -380,3 +380,45 @@ async fn harness_double_spawn_session_isolation() { server.await.unwrap(); let _ = tokio::fs::remove_dir_all(config.data_dir).await; } + +#[tokio::test] +async fn register_tenant_and_list_over_socket() { + // Retires the raw-SQLite insert in the first-proof runbook: register-tenant + // + list-tenants now flow through the socket, returning the tenant record. + let config = check_config(); + tokio::fs::create_dir_all(&config.data_dir).await.unwrap(); + + let state: SharedState = Arc::new(DaemonState::new(config.clone())); + let shutdown = state.shutdown_rx.resubscribe(); + let server_state = state.clone(); + let server = tokio::spawn(async move { + let _ = socket::serve(server_state, shutdown).await; + }); + + let client = DaemonClient::new(config.socket_path.clone()); + wait_for_socket(&client).await; + + let registered = client + .register_tenant("proof0", "/usr/local/bastille/jails/proof0/root", "proof0") + .await + .unwrap(); + assert_eq!(registered["tenant_id"], "proof0"); + assert_eq!(registered["status"], "provisioned"); + assert_eq!( + registered["jail_root_path"], + "/usr/local/bastille/jails/proof0/root" + ); + + let listed = client.list_tenants().await.unwrap(); + let found = listed + .as_array() + .unwrap() + .iter() + .find(|t| t["tenant_id"] == "proof0") + .expect("registered tenant appears in list-tenants"); + assert_eq!(found["collection_id"], "proof0"); + + let _ = state.shutdown_tx.send(()); + server.await.unwrap(); + let _ = tokio::fs::remove_dir_all(config.data_dir).await; +} diff --git a/crates/colibri-daemon/src/lib.rs b/crates/colibri-daemon/src/lib.rs index 18131fb..7d51486 100644 --- a/crates/colibri-daemon/src/lib.rs +++ b/crates/colibri-daemon/src/lib.rs @@ -92,6 +92,19 @@ pub enum ColibriCommand { }, #[serde(rename = "set-cost-mode")] SetCostMode { mode: String }, + // ── Vault tenants ───────────────────────────────────────── + /// Register a tenant: the 1:1:1 map (tenant_id = jail name = Vaultwarden + /// collection) that colibri-vault uses to know where to write `.env` after + /// jail creation. Wires the existing `store::register_tenant` to the socket + /// + CLI so the first-proof runbook no longer needs raw SQLite. + #[serde(rename = "register-tenant")] + RegisterTenant { + tenant_id: String, + jail_root_path: String, + collection_id: String, + }, + #[serde(rename = "list-tenants")] + ListTenants, } /// Outbound control-plane response. diff --git a/crates/colibri-daemon/src/socket.rs b/crates/colibri-daemon/src/socket.rs index 7e9d6d4..e050aac 100644 --- a/crates/colibri-daemon/src/socket.rs +++ b/crates/colibri-daemon/src/socket.rs @@ -278,6 +278,12 @@ async fn dispatch(cmd: ColibriCommand, state: &SharedState) -> ColibriResponse { capabilities, } => cmd_intake_task(state, title, description, capabilities).await, ColibriCommand::SetCostMode { mode } => cmd_set_cost_mode(state, mode).await, + ColibriCommand::RegisterTenant { + tenant_id, + jail_root_path, + collection_id, + } => cmd_register_tenant(state, tenant_id, jail_root_path, collection_id).await, + ColibriCommand::ListTenants => cmd_list_tenants(state).await, } } @@ -701,6 +707,30 @@ async fn cmd_register_agent( } } +async fn cmd_register_tenant( + state: &SharedState, + tenant_id: String, + jail_root_path: String, + collection_id: String, +) -> ColibriResponse { + match state + .store + .lock() + .unwrap() + .register_tenant(&tenant_id, &jail_root_path, &collection_id) + { + Ok(tenant) => ColibriResponse::ok(serde_json::to_value(tenant).unwrap_or_default()), + Err(e) => ColibriResponse::err(format!("register tenant failed: {e}")), + } +} + +async fn cmd_list_tenants(state: &SharedState) -> ColibriResponse { + match state.store.lock().unwrap().list_tenants() { + Ok(tenants) => ColibriResponse::ok(serde_json::to_value(tenants).unwrap_or_default()), + Err(e) => ColibriResponse::err(format!("list tenants failed: {e}")), + } +} + async fn cmd_list_skills(state: &SharedState) -> ColibriResponse { match state.store.lock().unwrap().list_skills() { Ok(skills) => ColibriResponse::ok(serde_json::to_value(skills).unwrap_or_default()), diff --git a/crates/colibri-vault/src/lib.rs b/crates/colibri-vault/src/lib.rs index 4c7abcb..5668105 100644 --- a/crates/colibri-vault/src/lib.rs +++ b/crates/colibri-vault/src/lib.rs @@ -384,6 +384,17 @@ struct SerdeLogin { password: Option, } +/// Validate and normalize a key for .env output. +/// Reject keys with non-[A-Z0-9_] characters (matching the clawdie-vault-fetch helper). +fn validate_key(raw: &str) -> String { + let key = raw.to_uppercase().replace([' ', '-', '.'], "_"); + if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !key.is_empty() { + key + } else { + String::new() + } +} + #[cfg(test)] mod tests { use super::*; @@ -466,14 +477,3 @@ mod tests { assert!(validate_key("").is_empty()); } } - -/// Validate and normalize a key for .env output. -/// Reject keys with non-[A-Z0-9_] characters (matching the clawdie-vault-fetch helper). -fn validate_key(raw: &str) -> String { - let key = raw.to_uppercase().replace([' ', '-', '.'], "_"); - if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !key.is_empty() { - key - } else { - String::new() - } -}