From cb124a014fe48d0a7cc6352bb53a7d130a5c0cfb Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 27 Jun 2026 18:55:09 +0200 Subject: [PATCH] feat(deploy): add colibri-deploy crate + MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate: colibri-deploy - run(target, command) → shell on host or Bastille jail - list_targets() → host + active Bastille jails (graceful sudo fallback) New MCP tools (2): - colibri_deploy_run: run a command on host or jail - colibri_deploy_targets: list available targets Tests: host echo, host fail, list_targets includes host. Tool count: 18 → 20 --- Cargo.lock | 8 ++ Cargo.toml | 2 +- crates/colibri-deploy/Cargo.toml | 8 ++ crates/colibri-deploy/src/lib.rs | 92 +++++++++++++++++++++++ crates/colibri-mcp/Cargo.toml | 1 + crates/colibri-mcp/src/lib.rs | 36 +++++++++ crates/colibri-mcp/tests/tool_dispatch.rs | 2 +- 7 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 crates/colibri-deploy/Cargo.toml create mode 100644 crates/colibri-deploy/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 8403d12..91cad77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "colibri-deploy" +version = "0.12.0" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colibri-glasspane" version = "0.12.0" @@ -386,6 +393,7 @@ dependencies = [ "clap", "colibri-client", "colibri-daemon", + "colibri-deploy", "colibri-pf", "colibri-zfs", "serde", diff --git a/Cargo.toml b/Cargo.toml index e34dbf7..0c6a372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/colibri-deploy", "crates/clawdie"] [workspace.package] version = "0.12.0" diff --git a/crates/colibri-deploy/Cargo.toml b/crates/colibri-deploy/Cargo.toml new file mode 100644 index 0000000..58c09d9 --- /dev/null +++ b/crates/colibri-deploy/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "colibri-deploy" +version.workspace = true +edition = "2021" +license = "MIT" + +[dependencies] +thiserror = "2" diff --git a/crates/colibri-deploy/src/lib.rs b/crates/colibri-deploy/src/lib.rs new file mode 100644 index 0000000..ae8cc10 --- /dev/null +++ b/crates/colibri-deploy/src/lib.rs @@ -0,0 +1,92 @@ +use std::process::Command; + +/// Run a command on a target. Supports "host" (local shell) and Bastille jail names. +pub fn run(target: &str, command: &str) -> Result { + match target { + "host" => run_host(command), + jail => run_jail(jail, command), + } +} + +/// List available deployment targets: "host" always present, +/// plus any Bastille jails reachable via passwordless sudo. +pub fn list_targets() -> Vec { + let mut targets = vec!["host".to_string()]; + if let Ok(output) = Command::new("sudo").args(["bastille", "list"]).output() { + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("JID") || line.starts_with('-') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + targets.push(parts[0].to_string()); + } + } + } + } + targets +} + +fn run_host(command: &str) -> Result { + let output = Command::new("sh") + .args(["-c", command]) + .output() + .map_err(|e| Error::Command(format!("host command: {e}")))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(Error::Command(format!( + "exit {}: {}", + output.status.code().unwrap_or(-1), + if stderr.is_empty() { stdout } else { stderr } + ))); + } + Ok(if stdout.is_empty() { stderr } else { stdout }) +} + +fn run_jail(jail: &str, command: &str) -> Result { + let output = Command::new("sudo") + .args(["bastille", "cmd", jail, command]) + .output() + .map_err(|e| Error::Command(format!("bastille cmd {jail}: {e}")))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let msg = if stderr.is_empty() { stdout } else { stderr }; + return Err(Error::Command(format!("jail {jail}: {msg}"))); + } + Ok(stdout) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("deploy error: {0}")] + Command(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_host_echo() { + let result = run("host", "echo hello").unwrap(); + assert_eq!(result.trim(), "hello"); + } + + #[test] + fn run_host_fail() { + assert!(run("host", "exit 1").is_err()); + } + + #[test] + fn list_targets_includes_host() { + let targets = list_targets(); + assert!(targets.iter().any(|t| t == "host")); + } +} diff --git a/crates/colibri-mcp/Cargo.toml b/crates/colibri-mcp/Cargo.toml index 11d6d6d..139d298 100644 --- a/crates/colibri-mcp/Cargo.toml +++ b/crates/colibri-mcp/Cargo.toml @@ -14,6 +14,7 @@ colibri-client = { path = "../colibri-client" } colibri-daemon = { path = "../colibri-daemon" } colibri-pf = { path = "../colibri-pf" } colibri-zfs = { path = "../colibri-zfs" } +colibri-deploy = { path = "../colibri-deploy" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs index 9fcd68d..9f0d638 100644 --- a/crates/colibri-mcp/src/lib.rs +++ b/crates/colibri-mcp/src/lib.rs @@ -207,6 +207,25 @@ pub fn tool_list() -> Vec { "List active PF state table entries: protocol, source, destination, state.", None, ), + // ── Deploy tools ── + json_tool( + "colibrie_deploy_run", + "Run a shell command on the host or in a Bastille jail. Use deploy_targets to list available targets.", + Some(serde_json::json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Target: host or jail name from deploy_targets" }, + "command": { "type": "string", "description": "Shell command to run" } + }, + "required": ["target", "command"] + })), + ), + json_tool( + "colibrie_deploy_targets", + "List deploy targets: host and available Bastille jails.", + None, + ), + // ── Wiki tools ── json_tool( "colibri_wiki_search", @@ -433,6 +452,23 @@ pub async fn dispatch_tool( serde_json::json!({"page": page, "content": content}), )) } + // ── Deploy dispatch ── + "colibri_deploy_run" => { + let target = require_string(arguments, "target")?; + let command = require_string(arguments, "command")?; + let output = colibri_deploy::run(&target, &command) + .map_err(|e| McpError::internal(format!("deploy: {e}")))?; + Ok(tool_text(serde_json::json!({ + "target": target, + "output": output, + }))) + } + "colibri_deploy_targets" => { + let targets = colibri_deploy::list_targets(); + Ok(tool_text( + serde_json::to_value(&targets).unwrap_or_default(), + )) + } "colibri_external_mcp_servers" => { let registry = external::load_registry_if_present(&config.external_config_path).await?; Ok(tool_text(serde_json::json!({ diff --git a/crates/colibri-mcp/tests/tool_dispatch.rs b/crates/colibri-mcp/tests/tool_dispatch.rs index 8e49767..978fefa 100644 --- a/crates/colibri-mcp/tests/tool_dispatch.rs +++ b/crates/colibri-mcp/tests/tool_dispatch.rs @@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() { assert!(names.contains(&"colibri_list_task_costs")); assert!(names.contains(&"colibri_get_task")); - assert_eq!(names.len(), 18); + assert_eq!(names.len(), 20); }