use std::collections::HashSet; use std::path::Path; use std::process::{Output, Stdio}; use std::time::Duration; use serde::{Deserialize, Serialize}; use tokio::process::Command; use crate::config::{Config, McpTransport, expand_path}; pub const SUPPORTED_AGENT_BROWSER_VERSION: &str = "0.33.0"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum HealthStatus { Pass, Warning, Fail, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum HealthOverall { Healthy, Degraded, Unhealthy, } #[derive(Debug, Clone, Serialize)] pub struct HealthCheck { pub name: String, pub category: String, pub required: bool, pub status: HealthStatus, pub detail: String, #[serde(skip_serializing_if = "Option::is_none")] pub remediation: Option, } #[derive(Debug, Clone, Serialize)] pub struct HealthReport { pub version: &'static str, pub overall: HealthOverall, pub checks: Vec, } impl HealthReport { pub fn configuration_error(error: impl Into) -> Self { Self::from_checks(vec![HealthCheck { name: "configuration".to_string(), category: "core".to_string(), required: true, status: HealthStatus::Fail, detail: error.into(), remediation: Some( "Fix ~/.picobot/config.json (or ./config.json) and run picobot health again." .to_string(), ), }]) } fn from_checks(checks: Vec) -> Self { let overall = if checks .iter() .any(|check| check.required && check.status == HealthStatus::Fail) { HealthOverall::Unhealthy } else if checks .iter() .any(|check| check.status != HealthStatus::Pass) { HealthOverall::Degraded } else { HealthOverall::Healthy }; Self { version: env!("CARGO_PKG_VERSION"), overall, checks, } } pub fn is_usable(&self) -> bool { self.overall != HealthOverall::Unhealthy } pub fn render_text(&self) -> String { let overall = match self.overall { HealthOverall::Healthy => "HEALTHY", HealthOverall::Degraded => "DEGRADED", HealthOverall::Unhealthy => "UNHEALTHY", }; let mut lines = vec![format!("PicoBot {} health: {overall}", self.version)]; for check in &self.checks { let icon = match check.status { HealthStatus::Pass => "✓", HealthStatus::Warning => "!", HealthStatus::Fail => "✗", }; let requirement = if check.required { "required" } else { "optional" }; lines.push(format!( "{icon} [{} / {requirement}] {} — {}", check.category, check.name, check.detail )); if let Some(remediation) = &check.remediation { lines.push(format!(" Fix: {remediation}")); } } lines.join("\n") } } #[derive(Clone)] pub struct HealthService { config: Config, } impl HealthService { pub fn new(config: Config) -> Self { Self { config } } pub async fn check(&self) -> HealthReport { let mut checks = vec![ check_configuration_recovery(&self.config), check_workspace(&self.config), check_required_binary( "bash", "core", "Install Bash and make it available on PATH.", ), check_search_backend("content search", &["rg", "grep"], &["rg"]), check_search_backend("file search", &["fd", "fdfind", "find"], &["fd", "fdfind"]), check_optional_binary("systemd service management", "systemctl", "service"), ]; checks.extend(self.check_mcp_commands()); checks.extend(self.check_browser().await); HealthReport::from_checks(checks) } fn check_mcp_commands(&self) -> Vec { let mut seen = HashSet::new(); let mut checks = Vec::new(); for server in &self.config.mcp.servers { if !server.enabled { continue; } if !matches!(server.transport, McpTransport::Stdio) { continue; } let Some(command) = server.command.as_deref() else { checks.push(HealthCheck { name: format!("MCP server {}", server.name), category: "configured".to_string(), required: true, status: HealthStatus::Fail, detail: "stdio server has no command".to_string(), remediation: Some("Set mcp.servers[].command.".to_string()), }); continue; }; if !seen.insert(command.to_string()) { continue; } let installed = command_exists(command); checks.push(HealthCheck { name: format!("MCP command {command}"), category: "configured".to_string(), required: true, status: if installed { HealthStatus::Pass } else { HealthStatus::Fail }, detail: if installed { "installed".to_string() } else { "not found on PATH".to_string() }, remediation: (!installed).then(|| { format!("Install '{command}' or set an absolute mcp.servers[].command path.") }), }); } if checks.is_empty() { checks.push(HealthCheck { name: "MCP stdio commands".to_string(), category: "configured".to_string(), required: false, status: HealthStatus::Pass, detail: "no stdio MCP servers configured".to_string(), remediation: None, }); } checks } async fn check_browser(&self) -> Vec { let browser = &self.config.browser; if !browser.enabled { return vec![HealthCheck { name: "agent-browser".to_string(), category: "configured".to_string(), required: false, status: HealthStatus::Pass, detail: "browser tool disabled; dependency not required".to_string(), remediation: None, }]; } let mut checks = Vec::new(); if !browser.allowed_domains.is_empty() { checks.push(HealthCheck { name: "persistent browser availability".to_string(), category: "configured".to_string(), required: false, status: HealthStatus::Warning, detail: "ordinary transient browsing is available, but persistent Chrome profiles are unavailable while allowed_domains is configured".to_string(), remediation: Some( "Keep allowed_domains for contained transient browsing, or clear it only if reusable persistent profiles are required; agent-browser 0.33.0 cannot combine both guarantees.".to_string(), ), }); } if !command_exists(&browser.command) { checks.push(HealthCheck { name: "agent-browser CLI".to_string(), category: "configured".to_string(), required: true, status: HealthStatus::Fail, detail: format!("'{}' was not found", browser.command), remediation: Some(format!( "Run `npm install -g agent-browser@{SUPPORTED_AGENT_BROWSER_VERSION}` (or `cargo install agent-browser --version {SUPPORTED_AGENT_BROWSER_VERSION} --locked`), then `agent-browser install`." )), }); return checks; } let version = command_output( &browser.command, &["--version"], &[], Duration::from_secs(5), ) .await; match version { Ok(version_output) => { let version_number = extract_version(&version_output); let exact = version_number.as_deref() == Some(SUPPORTED_AGENT_BROWSER_VERSION); checks.push(HealthCheck { name: "agent-browser CLI".to_string(), category: "configured".to_string(), required: true, status: if exact { HealthStatus::Pass } else { HealthStatus::Warning }, detail: format!( "installed version {}; PicoBot is validated with {}", version_number.unwrap_or_else(|| version_output.trim().to_string()), SUPPORTED_AGENT_BROWSER_VERSION ), remediation: (!exact).then(|| { format!( "Install agent-browser@{SUPPORTED_AGENT_BROWSER_VERSION} for the validated CLI contract." ) }), }); } Err(error) => checks.push(HealthCheck { name: "agent-browser CLI".to_string(), category: "configured".to_string(), required: true, status: HealthStatus::Fail, detail: error, remediation: Some("Reinstall agent-browser and verify it can execute.".to_string()), }), } if let Some(path) = browser.browser_executable_path.as_deref() { let path = expand_path(path); let path = if path.is_absolute() { path } else { expand_path(&self.config.workspace_dir).join(path) }; let installed = executable_file(&path); checks.push(HealthCheck { name: "configured browser executable".to_string(), category: "configured".to_string(), required: true, status: if installed { HealthStatus::Pass } else { HealthStatus::Fail }, detail: if installed { format!("executable file found at {}", path.display()) } else { format!("missing or not executable at {}", path.display()) }, remediation: (!installed).then(|| { "Fix browser.browser_executable_path and its execute permissions, or run `agent-browser install`.".to_string() }), }); } let doctor_executable = browser .browser_executable_path .as_deref() .map(expand_path) .map(|path| { if path.is_absolute() { path } else { expand_path(&self.config.workspace_dir).join(path) } }) .map(|path| path.to_string_lossy().into_owned()); let doctor_executable_env = doctor_executable .as_deref() .map(|path| ("AGENT_BROWSER_EXECUTABLE_PATH", path)); let doctor_socket_dir = match tempfile::Builder::new() .prefix("picobot-health-agent-browser-") .tempdir() { Ok(directory) => directory, Err(error) => { checks.push(agent_browser_doctor_failure(format!( "failed to create isolated doctor directory: {error}" ))); return checks; } }; let socket_dir = doctor_socket_dir.path().to_string_lossy().into_owned(); let mut doctor_env = vec![("AGENT_BROWSER_SOCKET_DIR", socket_dir.as_str())]; if let Some(env) = doctor_executable_env { doctor_env.push(env); } let doctor = capture_command( &browser.command, &[ "--namespace", "picobot-health", "doctor", "--offline", "--json", ], &doctor_env, Duration::from_secs(30), ) .await; match doctor { Ok(output) => match parse_agent_browser_doctor(&output) { Ok(report) => checks.extend(agent_browser_doctor_checks(&report)), Err(error) => checks.push(agent_browser_doctor_failure(error)), }, Err(error) => checks.push(agent_browser_doctor_failure(error)), } checks } } fn check_configuration_recovery(config: &Config) -> HealthCheck { if config.diagnostics.is_empty() { return HealthCheck { name: "configuration compatibility".to_string(), category: "core".to_string(), required: false, status: HealthStatus::Pass, detail: "no recoverable configuration problems detected".to_string(), remediation: None, }; } let paths = config .diagnostics .iter() .take(5) .map(|diagnostic| diagnostic.path.as_str()) .collect::>() .join(", "); HealthCheck { name: "configuration compatibility".to_string(), category: "core".to_string(), required: false, status: HealthStatus::Warning, detail: format!( "ignored {} recoverable configuration item(s): {paths}", config.diagnostics.len() ), remediation: Some( "Review and clean invalid entries in WebUI Settings → config.json.".to_string(), ), } } fn check_workspace(config: &Config) -> HealthCheck { let workspace = expand_path(&config.workspace_dir); let exists = workspace.is_dir(); HealthCheck { name: "workspace".to_string(), category: "core".to_string(), required: true, status: if exists { HealthStatus::Pass } else { HealthStatus::Fail }, detail: if exists { format!("{} is available", workspace.display()) } else { format!("{} does not exist", workspace.display()) }, remediation: (!exists) .then(|| "Create the configured workspace directory or fix workspace_dir.".to_string()), } } fn check_required_binary(name: &str, category: &str, remediation: &str) -> HealthCheck { let installed = command_exists(name); HealthCheck { name: name.to_string(), category: category.to_string(), required: true, status: if installed { HealthStatus::Pass } else { HealthStatus::Fail }, detail: if installed { "installed".to_string() } else { "not found on PATH".to_string() }, remediation: (!installed).then(|| remediation.to_string()), } } fn check_search_backend(name: &str, candidates: &[&str], preferred: &[&str]) -> HealthCheck { let found = candidates.iter().copied().find(|name| command_exists(name)); let (status, detail, remediation) = search_backend_result(found, candidates, preferred); HealthCheck { name: name.to_string(), category: "core".to_string(), required: true, status, detail, remediation, } } fn search_backend_result( found: Option<&str>, candidates: &[&str], preferred: &[&str], ) -> (HealthStatus, String, Option) { match found { Some(found) if preferred.contains(&found) => ( HealthStatus::Pass, format!("using preferred backend {found}"), None, ), Some(found) => ( HealthStatus::Warning, format!("using fallback backend {found}"), Some(format!( "Install {} for faster searches.", preferred.join(" or ") )), ), None => ( HealthStatus::Fail, "no supported backend found".to_string(), Some(format!("Install one of: {}.", candidates.join(", "))), ), } } fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthCheck { let installed = command_exists(binary); HealthCheck { name: name.to_string(), category: category.to_string(), required: false, status: HealthStatus::Pass, detail: if installed { format!("{binary} installed") } else { format!("{binary} not installed; feature remains unavailable") }, remediation: None, } } fn command_exists(command: &str) -> bool { if command.contains(std::path::MAIN_SEPARATOR) { executable_file(Path::new(command)) } else { which::which(command).is_ok() } } fn executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; if !metadata.is_file() { return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] { true } } async fn capture_command( command: &str, args: &[&str], env: &[(&str, &str)], timeout: Duration, ) -> Result { let mut process = Command::new(command); process .args(args) .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); tokio::time::timeout(timeout, process.output()) .await .map_err(|_| format!("command timed out after {} seconds", timeout.as_secs()))? .map_err(|error| format!("failed to start: {error}")) } async fn command_output( command: &str, args: &[&str], env: &[(&str, &str)], timeout: Duration, ) -> Result { let output = capture_command(command, args, env, timeout).await?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { let detail = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; return Err(format!( "exited with {}: {}", output.status, truncate(detail, 1_000) )); } let combined = if stdout.trim().is_empty() { stderr.trim() } else { stdout.trim() }; Ok(truncate(combined, 4_000)) } #[derive(Debug, Deserialize)] struct AgentBrowserDoctorReport { #[serde(default)] checks: Vec, #[serde(default)] success: bool, } #[derive(Debug, Deserialize)] struct AgentBrowserDoctorCheck { id: String, message: String, status: String, } fn parse_agent_browser_doctor(output: &Output) -> Result { const MAX_DOCTOR_OUTPUT_BYTES: usize = 64 * 1024; if output.stdout.len() > MAX_DOCTOR_OUTPUT_BYTES { return Err(format!( "agent-browser doctor returned more than {MAX_DOCTOR_OUTPUT_BYTES} bytes" )); } let stdout = String::from_utf8_lossy(&output.stdout); match serde_json::from_str::(stdout.trim()) { Ok(report) => Ok(report), Err(error) => { let stderr = String::from_utf8_lossy(&output.stderr); let detail = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; Err(format!( "agent-browser doctor returned invalid JSON ({error}): {}", truncate(detail, 1_000) )) } } } fn agent_browser_doctor_checks(report: &AgentBrowserDoctorReport) -> Vec { let mut installation = doctor_named_check( report, "browser installation", |check| check.id == "chrome.installed", "Run `agent-browser install`, or configure browser.browser_executable_path.", ); let launch = doctor_named_check( report, "browser headless launch", |check| check.id.starts_with("launch."), "Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms.", ); if installation.status == HealthStatus::Fail && launch.status == HealthStatus::Pass { installation.status = HealthStatus::Pass; installation.detail = "headless launch confirmed an available configured or system browser".to_string(); installation.remediation = None; } let other_issues = report .checks .iter() .filter(|check| { check.id != "chrome.installed" && !check.id.starts_with("launch.") && matches!(check.status.as_str(), "warn" | "warning" | "fail") }) .collect::>(); let unexplained_failure = !report.success && !report .checks .iter() .any(|check| check.status.as_str() == "fail"); let runtime_status = if other_issues .iter() .any(|check| check.status.as_str() == "fail") || unexplained_failure { HealthStatus::Fail } else if other_issues.is_empty() { HealthStatus::Pass } else { HealthStatus::Warning }; let runtime_detail = if other_issues.is_empty() { if runtime_status == HealthStatus::Pass { "isolated offline doctor completed without environment warnings".to_string() } else { "doctor reported failure without a structured failing check".to_string() } } else { let details = other_issues .iter() .take(3) .map(|check| format!("{}: {}", check.id, check.message)) .collect::>() .join("; "); truncate( &format!("{} environment issue(s): {details}", other_issues.len()), 1_000, ) }; let runtime = HealthCheck { name: "agent-browser environment".to_string(), category: "configured".to_string(), required: true, status: runtime_status, detail: runtime_detail, remediation: (runtime_status != HealthStatus::Pass).then(|| { "Run `agent-browser doctor --debug` to inspect the reported environment checks." .to_string() }), }; vec![installation, launch, runtime] } fn doctor_named_check( report: &AgentBrowserDoctorReport, name: &str, predicate: impl Fn(&AgentBrowserDoctorCheck) -> bool, remediation: &str, ) -> HealthCheck { let found = report.checks.iter().find(|check| predicate(check)); let (status, detail) = match found { Some(check) => (doctor_status(&check.status), check.message.clone()), None => ( HealthStatus::Fail, format!("agent-browser doctor did not report {name}"), ), }; HealthCheck { name: name.to_string(), category: "configured".to_string(), required: true, status, detail, remediation: (status != HealthStatus::Pass).then(|| remediation.to_string()), } } fn doctor_status(status: &str) -> HealthStatus { match status { "pass" | "info" => HealthStatus::Pass, "warn" | "warning" => HealthStatus::Warning, "fail" => HealthStatus::Fail, _ => HealthStatus::Warning, } } fn agent_browser_doctor_failure(detail: String) -> HealthCheck { HealthCheck { name: "agent-browser runtime".to_string(), category: "configured".to_string(), required: true, status: HealthStatus::Fail, detail, remediation: Some( "Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms." .to_string(), ), } } fn extract_version(output: &str) -> Option { output .split_whitespace() .map(|token| { token .trim_start_matches('v') .trim_matches(|c: char| c == ',' || c == ';') }) .find(|token| { let mut parts = token.split('.'); parts.clone().count() >= 3 && parts.all(|part| part.chars().all(|c| c.is_ascii_digit())) }) .map(str::to_string) } fn truncate(value: &str, max: usize) -> String { if value.len() <= max { value.to_string() } else { format!("{}…", &value[..value.floor_char_boundary(max)]) } } #[cfg(test)] mod tests { use super::*; #[test] fn required_failure_makes_report_unhealthy() { let report = HealthReport::from_checks(vec![HealthCheck { name: "x".into(), category: "core".into(), required: true, status: HealthStatus::Fail, detail: "missing".into(), remediation: None, }]); assert_eq!(report.overall, HealthOverall::Unhealthy); assert!(!report.is_usable()); } #[test] fn report_serializes_the_management_api_contract() { let report = HealthReport::from_checks(vec![HealthCheck { name: "content search".into(), category: "core".into(), required: true, status: HealthStatus::Warning, detail: "using fallback backend".into(), remediation: Some("Install rg.".into()), }]); let value = serde_json::to_value(report).unwrap(); assert_eq!(value["version"], env!("CARGO_PKG_VERSION")); assert_eq!(value["overall"], "degraded"); assert_eq!(value["checks"][0]["status"], "warning"); assert_eq!(value["checks"][0]["required"], true); assert_eq!(value["checks"][0]["remediation"], "Install rg."); } #[test] fn fdfind_is_a_preferred_file_search_backend() { let (status, detail, remediation) = search_backend_result(Some("fdfind"), &["fd", "fdfind", "find"], &["fd", "fdfind"]); assert_eq!(status, HealthStatus::Pass); assert_eq!(detail, "using preferred backend fdfind"); assert_eq!(remediation, None); let (status, _, remediation) = search_backend_result(Some("find"), &["fd", "fdfind", "find"], &["fd", "fdfind"]); assert_eq!(status, HealthStatus::Warning); assert_eq!( remediation.as_deref(), Some("Install fd or fdfind for faster searches.") ); } #[test] fn doctor_report_exposes_install_launch_and_environment_checks() { let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({ "success": true, "checks": [ {"id": "env.version", "message": "CLI version 0.33.0", "status": "pass"}, {"id": "chrome.installed", "message": "Chromium found", "status": "pass"}, {"id": "launch.elapsed", "message": "Headless launch in 1.2s", "status": "pass"} ] })) .unwrap(); let checks = agent_browser_doctor_checks(&report); assert_eq!(checks.len(), 3); assert_eq!(checks[0].name, "browser installation"); assert_eq!(checks[0].status, HealthStatus::Pass); assert_eq!(checks[1].name, "browser headless launch"); assert_eq!(checks[1].status, HealthStatus::Pass); assert_eq!(checks[2].name, "agent-browser environment"); assert_eq!(checks[2].status, HealthStatus::Pass); } #[test] fn doctor_launch_failure_is_required_and_actionable() { let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({ "success": false, "checks": [ {"id": "env.disk_free", "message": "low disk", "status": "warn"}, {"id": "chrome.installed", "message": "Chromium found", "status": "pass"}, {"id": "launch.daemon", "message": "shared library missing", "status": "fail"} ] })) .unwrap(); let checks = agent_browser_doctor_checks(&report); assert_eq!(checks[1].status, HealthStatus::Fail); assert!(checks[1].required); assert!(checks[1].detail.contains("shared library missing")); assert!(checks[1].remediation.is_some()); assert_eq!(checks[2].status, HealthStatus::Warning); } #[test] fn successful_launch_accepts_a_configured_browser_without_bundled_chrome() { let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({ "success": false, "checks": [ {"id": "chrome.installed", "message": "Chrome for Testing missing", "status": "fail"}, {"id": "launch.elapsed", "message": "Headless launch in 0.8s", "status": "pass"} ] })) .unwrap(); let checks = agent_browser_doctor_checks(&report); assert_eq!(checks[0].status, HealthStatus::Pass); assert!(checks[0].detail.contains("launch confirmed")); assert_eq!(checks[1].status, HealthStatus::Pass); assert_eq!(checks[2].status, HealthStatus::Pass); } #[test] fn extracts_agent_browser_version() { assert_eq!( extract_version("agent-browser 0.33.0"), Some("0.33.0".to_string()) ); } }