//! Boot-time `claude plugin install` driver. Reads the list declared //! via the `services.hyperhive.agent.claudePlugins` NixOS option (rendered to //! `/etc/hyperhive/claude-plugins.json` by the harness module) and //! shells out `claude plugin install ` for each entry. Runs once //! per harness boot before the turn loop; `claude plugin install` //! is expected to be idempotent so reinstalling on each container //! recreate is fine. Failures log a warning but do not abort boot — //! we'd rather start without a plugin than refuse to serve. //! //! Before installing, all configured marketplaces are updated so that //! plugin specs resolve against current index data. Marketplace update //! failures are non-fatal — stale index is better than no install attempt. use std::io; use std::process::Output; use std::time::{Duration, Instant}; use tokio::process::Command; const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json"; const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json"; const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json"; /// Per-call deadline for `claude plugin …`; a git clone over a slow link fits, a hung fetch does not. const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_mins(2); /// Total budget for the whole serial sequence in [`install_configured`]. The /// harness awaits it before serving the web UI, so this is the longest a /// blackholed fetch path can delay boot, however many marketplaces and plugins /// are configured. const PLUGIN_BOOT_BUDGET: Duration = Duration::from_mins(4); /// The deadline for the next call: [`PLUGIN_CALL_TIMEOUT`], cut to what is /// left of the boot budget ending at `boot_end` (zero once it is spent). fn call_budget(now: Instant, boot_end: Instant) -> Duration { PLUGIN_CALL_TIMEOUT.min(boot_end.saturating_duration_since(now)) } /// Run `claude plugin ` under [`call_budget`]. On timeout the child is /// killed (`kill_on_drop`) and a `TimedOut` error is returned. Only the /// `claude` process is killed; a `git` it forked is not. async fn run_claude_plugin(args: &[&str], boot_end: Instant) -> io::Result { let mut cmd = Command::new("claude"); cmd.arg("plugin").args(args); run_bounded(cmd, call_budget(Instant::now(), boot_end)).await } /// Run `cmd` to completion within `deadline`; the child is killed when the /// deadline passes. async fn run_bounded(mut cmd: Command, deadline: Duration) -> io::Result { let call = cmd.kill_on_drop(true).output(); tokio::time::timeout(deadline, call) .await .unwrap_or_else(|_| { Err(io::Error::new( io::ErrorKind::TimedOut, format!("no exit within {}s", deadline.as_secs_f32()), )) }) } /// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json` /// via `claude plugin marketplace add `. Idempotent: re-add of /// an existing marketplace is treated as success (claude prints an /// "already exists" message and exits non-zero on some versions). /// Required before any `@` install can resolve. async fn add_marketplaces(boot_end: Instant) { let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else { return; }; let sources: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping"); return; } }; for source in sources { match run_claude_plugin(&["marketplace", "add", &source], boot_end).await { Ok(out) if out.status.success() => { tracing::info!(source = %source, "claude plugin marketplace add ok"); } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("already") { tracing::debug!(source = %source, "marketplace already added"); } else { tracing::warn!( source = %source, status = ?out.status, stderr = %stderr, "claude plugin marketplace add failed (non-fatal)", ); } } Err(e) => { tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed or timed out"); } } } } /// Read the `services.hyperhive.agent.claudePluginsAutoUpdate` flag written by the NixOS /// module. Defaults to `false` when the file is absent or unparseable. async fn auto_update_enabled() -> bool { match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await { Ok(s) => serde_json::from_str::(s.trim()).unwrap_or(false), Err(_) => false, } } /// Update all configured plugin marketplaces. Non-fatal — logs a warning /// on failure but does not abort the install sequence. async fn update_marketplaces(boot_end: Instant) { match run_claude_plugin(&["marketplace", "update"], boot_end).await { Ok(out) if out.status.success() => { tracing::info!("claude plugin marketplace update ok"); } Ok(out) => { tracing::warn!( status = ?out.status, stderr = %String::from_utf8_lossy(&out.stderr), "claude plugin marketplace update failed (non-fatal)", ); } Err(e) => { tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed or timed out (non-fatal)"); } } } /// Install every plugin in `/etc/hyperhive/claude-plugins.json`. /// A failed install is logged and skipped; the harness starts without it. pub async fn install_configured() { let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else { return; }; let specs: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping"); return; } }; if specs.is_empty() { return; } let boot_end = Instant::now() + PLUGIN_BOOT_BUDGET; add_marketplaces(boot_end).await; if auto_update_enabled().await { update_marketplaces(boot_end).await; } else { tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update"); } for spec in specs { match run_claude_plugin(&["install", &spec], boot_end).await { Ok(out) if out.status.success() => { tracing::info!(spec = %spec, "claude plugin install ok"); } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); tracing::warn!( spec = %spec, status = ?out.status, stderr = %stderr, "claude plugin install failed", ); } Err(e) => { tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed or timed out"); } } } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn run_bounded_times_out_a_hung_child() { let mut cmd = Command::new("sleep"); cmd.arg("30"); let err = run_bounded(cmd, Duration::from_millis(100)) .await .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::TimedOut); } #[tokio::test] async fn run_bounded_returns_output_of_a_prompt_child() { let cmd = Command::new("true"); let out = run_bounded(cmd, Duration::from_secs(5)).await; assert!(out.unwrap().status.success()); } #[test] fn serial_calls_never_exceed_the_boot_budget() { let start = Instant::now(); let boot_end = start + PLUGIN_BOOT_BUDGET; let mut now = start; // Worst case: every call hangs until its own deadline. for _ in 0..50 { now += call_budget(now, boot_end); } assert_eq!(now - start, PLUGIN_BOOT_BUDGET); assert_eq!(call_budget(now, boot_end), Duration::ZERO); } #[test] fn a_call_gets_the_full_timeout_while_budget_remains() { let start = Instant::now(); let boot_end = start + PLUGIN_BOOT_BUDGET; assert_eq!(call_budget(start, boot_end), PLUGIN_CALL_TIMEOUT); } }