From 76628eb8470a246394180dddea74ad3e89df1a9e Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sun, 21 Jun 2026 18:12:04 +0200 Subject: [PATCH] feat(daemon): auto-spawn a Pi agent on startup (Operator Image OOTB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workstream B of the next ISO rebuild: the live image should boot with at least one Pi instance live on the Colibri board without operator action. On startup, after the control-plane socket is up, the daemon spawns one DeepSeek-backed Pi when configured. Host-spawn (no jail) — the live image is single-agent; jails remain for deployed multi-tenant hosts. The Pi inherits DEEPSEEK_API_KEY from the daemon environment (sourced from provider.env by the rc.d service). - Gated by COLIBRI_AUTOSPAWN_PI (YES/1/true/on); no-op otherwise. - Requires a DEEPSEEK_API_KEY; logs and skips if absent (operator adds it via Join Hive, then the daemon restart spawns it). - Idempotent: skips if a Pi subprocess is already running, so the post-creds restart does not stack duplicates. - Pi binary and argv are env-tunable (COLIBRI_PI_BINARY default `pi`, COLIBRI_AUTOSPAWN_PI_ARGS default `--mode json`) so the exact invocation can be finalized on the FreeBSD image without a rebuild. Reuses cmd_spawn_agent so glasspane attach, stdout streaming, and board registration are identical to an operator-issued spawn. Tests for the pure helpers (basename, env_truthy); full daemon suite green; clippy clean. Co-Authored-By: Claude Opus 4.8 --- crates/colibri-daemon/src/main.rs | 5 ++ crates/colibri-daemon/src/socket.rs | 113 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/crates/colibri-daemon/src/main.rs b/crates/colibri-daemon/src/main.rs index 729d76d..58e8868 100644 --- a/crates/colibri-daemon/src/main.rs +++ b/crates/colibri-daemon/src/main.rs @@ -74,6 +74,11 @@ async fn main() -> Result<(), Box> { let socket_handle = tokio::spawn(async move { socket::serve(socket_state, socket_shutdown).await }); + // Auto-spawn one Pi agent if configured (live "Operator Image" OOTB flow). + // Runs after the socket server is up so the spawn registers on the board; + // no-op unless COLIBRI_AUTOSPAWN_PI is set and a DeepSeek key is present. + socket::autospawn_pi_if_configured(&state).await; + // Start the daemon background loop (heartbeat, session rotation, scheduler) let loop_state = state.clone(); let loop_shutdown = state.shutdown_rx.resubscribe(); diff --git a/crates/colibri-daemon/src/socket.rs b/crates/colibri-daemon/src/socket.rs index e050aac..198e84c 100644 --- a/crates/colibri-daemon/src/socket.rs +++ b/crates/colibri-daemon/src/socket.rs @@ -399,6 +399,97 @@ async fn cmd_list_sessions(state: &SharedState) -> ColibriResponse { })) } +/// Auto-spawn a single Pi agent at daemon startup when configured. +/// +/// Enabled with `COLIBRI_AUTOSPAWN_PI` (`YES`/`1`/`true`/`on`). Requires a +/// `DEEPSEEK_API_KEY` in the daemon environment (sourced from `provider.env`) — +/// the host-spawned Pi inherits it. Idempotent: skips when a Pi subprocess is +/// already running, so a daemon restart (e.g. after the operator enters creds +/// and `service colibri_daemon restart` runs) does not stack duplicates. +/// +/// The live "Operator Image" is single-agent, so the Pi runs on the host (no +/// jail). Binary and argv are env-tunable so the exact Pi invocation can be +/// adjusted on the image without a rebuild: +/// - `COLIBRI_PI_BINARY` (default `pi`) +/// - `COLIBRI_AUTOSPAWN_PI_ARGS` (default `--mode json`) +pub async fn autospawn_pi_if_configured(state: &SharedState) { + if !env_truthy("COLIBRI_AUTOSPAWN_PI") { + return; + } + if state.config.deepseek_api_key.is_none() { + info!( + "autospawn-pi: DEEPSEEK_API_KEY not set; skipping (operator can add it via Join Hive)" + ); + return; + } + + let pi_binary = std::env::var("COLIBRI_PI_BINARY") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "pi".to_string()); + + // One Pi is enough. Match by binary basename so a restart does not duplicate. + let pi_name = basename(&pi_binary); + let already = state + .agents + .iter() + .any(|e| basename(&e.value().config.binary) == pi_name); + if already { + info!(pi = %pi_name, "autospawn-pi: a Pi agent is already running; skipping"); + return; + } + + let args: Vec = std::env::var("COLIBRI_AUTOSPAWN_PI_ARGS") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "--mode json".to_string()) + .split_whitespace() + .map(str::to_string) + .collect(); + + info!(binary = %pi_binary, ?args, "autospawn-pi: spawning Pi agent on host (DeepSeek-backed)"); + + // provider=local → binary is the Pi executable; jail=None → host-spawn. + let resp = cmd_spawn_agent( + state, + "local".to_string(), + pi_binary, + None, + None, + Some(args), + None, + ) + .await; + + if resp.ok { + info!("autospawn-pi: Pi agent spawned"); + } else { + warn!( + error = resp.error.as_deref().unwrap_or("unknown"), + "autospawn-pi: spawn failed (continuing; operator can spawn manually)" + ); + } +} + +fn basename(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(path) + .to_string() +} + +fn env_truthy(name: &str) -> bool { + std::env::var(name) + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "yes" | "true" | "on" + ) + }) + .unwrap_or(false) +} + async fn cmd_spawn_agent( state: &SharedState, provider_str: String, @@ -802,6 +893,28 @@ mod tests { use crate::{DaemonConfig, DaemonState}; + #[test] + fn basename_extracts_file_name() { + assert_eq!(basename("/usr/local/bin/pi"), "pi"); + assert_eq!(basename("pi"), "pi"); + assert_eq!(basename("/opt/agents/zot"), "zot"); + } + + #[test] + fn env_truthy_accepts_common_affirmatives() { + let key = "COLIBRI_TEST_AUTOSPAWN_TRUTHY"; + for v in ["1", "yes", "YES", "true", "On"] { + std::env::set_var(key, v); + assert!(env_truthy(key), "expected {v} to be truthy"); + } + for v in ["0", "no", "", "off", "maybe"] { + std::env::set_var(key, v); + assert!(!env_truthy(key), "expected {v} to be falsey"); + } + std::env::remove_var(key); + assert!(!env_truthy(key)); + } + fn test_config() -> DaemonConfig { let data_dir = std::env::temp_dir().join(format!( "colibri-daemon-socket-test-{}",