Merge feat/register-tenant-and-jail-spawn-flags — CLI register-tenant + jail spawn flags
This commit is contained in:
commit
2e6d4339eb
6 changed files with 311 additions and 21 deletions
|
|
@ -20,6 +20,7 @@ enum Command {
|
|||
model: String,
|
||||
session_id: Option<String>,
|
||||
system_prompt: Option<String>,
|
||||
jail: Option<colibri_client::JailConfig>,
|
||||
},
|
||||
KillAgent {
|
||||
agent_id: String,
|
||||
|
|
@ -52,6 +53,12 @@ enum Command {
|
|||
name: String,
|
||||
capabilities: Vec<String>,
|
||||
},
|
||||
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<String>, Option<String>), String> {
|
||||
fn parse_spawn_options(args: &[String]) -> Result<SpawnOptions, String> {
|
||||
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<String>, Option<String
|
|||
system_prompt = Some(value.clone());
|
||||
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())),
|
||||
}
|
||||
}
|
||||
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> {
|
||||
|
|
@ -363,10 +443,21 @@ fn parse_capabilities(args: &[String]) -> Result<Vec<String>, 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
session_id: 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> {
|
||||
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<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(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -384,6 +384,17 @@ struct SerdeLogin {
|
|||
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)]
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue