Merge feat/register-tenant-and-jail-spawn-flags — CLI register-tenant + jail spawn flags
Some checks failed
CI / rust (pull_request) Has been cancelled
CI / markdown (pull_request) Has been cancelled

This commit is contained in:
Sam & Claude 2026-06-20 14:19:09 +02:00
commit 2e6d4339eb
6 changed files with 311 additions and 21 deletions

View file

@ -20,6 +20,7 @@ enum Command {
model: String, model: String,
session_id: Option<String>, session_id: Option<String>,
system_prompt: Option<String>, system_prompt: Option<String>,
jail: Option<colibri_client::JailConfig>,
}, },
KillAgent { KillAgent {
agent_id: String, agent_id: String,
@ -52,6 +53,12 @@ enum Command {
name: String, name: String,
capabilities: Vec<String>, capabilities: Vec<String>,
}, },
RegisterTenant {
tenant_id: String,
jail_root_path: String,
collection_id: String,
},
ListTenants,
ListAgents, ListAgents,
} }
@ -64,8 +71,8 @@ fn usage() -> &'static str {
colibri [--socket PATH] status colibri [--socket PATH] status
colibri [--socket PATH] snapshot colibri [--socket PATH] snapshot
colibri [--socket PATH] list-sessions colibri [--socket PATH] list-sessions
colibri [--socket PATH] spawn-local EXECUTABLE [--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] 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] kill AGENT_ID
colibri [--socket PATH] get-session SESSION_ID colibri [--socket PATH] get-session SESSION_ID
colibri [--socket PATH] compact-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] intake-task --title TEXT [--description TEXT] [--capability CAP]...
colibri [--socket PATH] list-skills colibri [--socket PATH] list-skills
colibri [--socket PATH] register-skill NAME [--description TEXT] [--category CAT] 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. 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 register-skill freebsd-check --description "Live USB startup check" --category freebsd
colibri list-skills colibri list-skills
colibri register-agent NAME [--capability CAP]... [--capabilities CSV] 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 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 { if args.len() < 2 {
Err("spawn-local requires EXECUTABLE\n\n".to_string() + usage()) Err("spawn-local requires EXECUTABLE\n\n".to_string() + usage())
} else { } 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 { Ok(Command::SpawnAgent {
provider: "local".to_string(), provider: "local".to_string(),
model: args[1].clone(), model: args[1].clone(),
session_id, session_id,
system_prompt, system_prompt,
jail,
}) })
} }
} }
@ -141,12 +159,17 @@ where
if args.len() < 3 { if args.len() < 3 {
Err("spawn-agent requires PROVIDER MODEL\n\n".to_string() + usage()) Err("spawn-agent requires PROVIDER MODEL\n\n".to_string() + usage())
} else { } 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 { Ok(Command::SpawnAgent {
provider: args[1].clone(), provider: args[1].clone(),
model: args[2].clone(), model: args[2].clone(),
session_id, session_id,
system_prompt, 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())), other => Err(format!("unknown command: {other}\n\n{}", usage())),
}?; }?;
@ -321,9 +360,11 @@ fn parse_intake_task_options(
Ok((title, description, capabilities)) Ok((title, description, capabilities))
} }
fn parse_spawn_options(args: &[String]) -> Result<(Option<String>, Option<String>), String> { fn parse_spawn_options(args: &[String]) -> Result<SpawnOptions, String> {
let mut session_id = None; let mut session_id = None;
let mut system_prompt = None; let mut system_prompt = None;
let mut jail_name = None;
let mut jail_root = None;
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
match args[i].as_str() { match args[i].as_str() {
@ -341,10 +382,49 @@ fn parse_spawn_options(args: &[String]) -> Result<(Option<String>, Option<String
system_prompt = Some(value.clone()); system_prompt = Some(value.clone());
i += 2; i += 2;
} }
// FreeBSD jail confinement. `--jail-name` enters an existing
// persistent jail (jexec); pair it with `--jail-root` so the vault
// provision hook can write `.env` into the host-visible jail root.
// This is the only flag combination that triggers vault provisioning
// after spawn (the provision hook matches on jail name + root path).
"--jail-name" => {
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())), 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<String>,
system_prompt: Option<String>,
jail: Option<colibri_client::JailConfig>,
} }
fn parse_capabilities(args: &[String]) -> Result<Vec<String>, String> { fn parse_capabilities(args: &[String]) -> Result<Vec<String>, String> {
@ -363,10 +443,21 @@ fn parse_capabilities(args: &[String]) -> Result<Vec<String>, String> {
let Some(value) = args.get(i + 1) else { let Some(value) = args.get(i + 1) else {
return Err("--capabilities requires CSV\n\n".to_string() + usage()); 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; 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) Ok(caps)
@ -414,9 +505,10 @@ async fn run(options: Options) -> Result<(), ClientError> {
model, model,
session_id, session_id,
system_prompt, system_prompt,
jail,
} => print_json( } => print_json(
&client &client
.spawn_agent(provider, model, session_id, system_prompt) .spawn_agent_with(provider, model, session_id, system_prompt, jail)
.await?, .await?,
), ),
Command::KillAgent { agent_id } => print_json(&client.kill_agent(agent_id).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 } => { Command::RegisterAgent { name, capabilities } => {
print_json(&client.register_agent(name, capabilities).await?) 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?), Command::ListAgents => print_json(&client.list_agents().await?),
} }
} }
@ -511,6 +613,7 @@ mod tests {
model: "/tmp/fake-agent".to_string(), model: "/tmp/fake-agent".to_string(),
session_id: Some("s1".to_string()), session_id: Some("s1".to_string()),
system_prompt: Some("hello".to_string()), system_prompt: Some("hello".to_string()),
jail: None,
}, },
} }
); );
@ -579,4 +682,65 @@ mod tests {
let err = parse_args(["bogus"]).unwrap_err(); let err = parse_args(["bogus"]).unwrap_err();
assert!(err.contains("unknown command")); 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"));
}
} }

View file

@ -9,6 +9,10 @@ use std::path::{Path, PathBuf};
use colibri_daemon::{ColibriCommand, ColibriResponse}; use colibri_daemon::{ColibriCommand, ColibriResponse};
use colibri_glasspane::GlasspaneSnapshot; 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 serde::de::DeserializeOwned;
use thiserror::Error; use thiserror::Error;
use tokio::{ use tokio::{
@ -102,6 +106,21 @@ impl DaemonClient {
model: impl Into<String>, model: impl Into<String>,
session_id: Option<String>, session_id: Option<String>,
system_prompt: Option<String>, system_prompt: Option<String>,
) -> Result<serde_json::Value, ClientError> {
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<String>,
model: impl Into<String>,
session_id: Option<String>,
system_prompt: Option<String>,
jail: Option<JailConfig>,
) -> Result<serde_json::Value, ClientError> { ) -> Result<serde_json::Value, ClientError> {
self.request(&ColibriCommand::SpawnAgent { self.request(&ColibriCommand::SpawnAgent {
provider: provider.into(), provider: provider.into(),
@ -109,7 +128,7 @@ impl DaemonClient {
session_id, session_id,
system_prompt, system_prompt,
local_args: None, local_args: None,
jail: None, jail,
}) })
.await .await
} }
@ -197,6 +216,28 @@ impl DaemonClient {
self.request(&ColibriCommand::ListAgents).await 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<String>,
jail_root_path: impl Into<String>,
collection_id: impl Into<String>,
) -> Result<serde_json::Value, ClientError> {
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<serde_json::Value, ClientError> {
self.request(&ColibriCommand::ListTenants).await
}
pub async fn register_skill( pub async fn register_skill(
&self, &self,
name: impl Into<String>, name: impl Into<String>,

View file

@ -380,3 +380,45 @@ async fn harness_double_spawn_session_isolation() {
server.await.unwrap(); server.await.unwrap();
let _ = tokio::fs::remove_dir_all(config.data_dir).await; 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;
}

View file

@ -92,6 +92,19 @@ pub enum ColibriCommand {
}, },
#[serde(rename = "set-cost-mode")] #[serde(rename = "set-cost-mode")]
SetCostMode { mode: String }, 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. /// Outbound control-plane response.

View file

@ -278,6 +278,12 @@ async fn dispatch(cmd: ColibriCommand, state: &SharedState) -> ColibriResponse {
capabilities, capabilities,
} => cmd_intake_task(state, title, description, capabilities).await, } => cmd_intake_task(state, title, description, capabilities).await,
ColibriCommand::SetCostMode { mode } => cmd_set_cost_mode(state, mode).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 { async fn cmd_list_skills(state: &SharedState) -> ColibriResponse {
match state.store.lock().unwrap().list_skills() { match state.store.lock().unwrap().list_skills() {
Ok(skills) => ColibriResponse::ok(serde_json::to_value(skills).unwrap_or_default()), Ok(skills) => ColibriResponse::ok(serde_json::to_value(skills).unwrap_or_default()),

View file

@ -384,6 +384,17 @@ struct SerdeLogin {
password: Option<String>, password: Option<String>,
} }
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -466,14 +477,3 @@ mod tests {
assert!(validate_key("").is_empty()); 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()
}
}