diff --git a/README.md b/README.md index f8160ff2e..1de31585f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ [![codecov](https://codecov.io/gh/dallay/corvus/graph/badge.svg?token=N4THEP2OF1)](https://codecov.io/gh/dallay/corvus) [![License](https://img.shields.io/github/license/dallay/corvus?color=blue)](LICENSE) [![Version](https://img.shields.io/badge/version-0.1.14-blue.svg)](gradle.properties) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/dallay/corvus/compare) ## 🛡️ Code Quality (SonarCloud) diff --git a/clients/agent-runtime/src/channels/mod.rs b/clients/agent-runtime/src/channels/mod.rs index f778d2f68..576894729 100755 --- a/clients/agent-runtime/src/channels/mod.rs +++ b/clients/agent-runtime/src/channels/mod.rs @@ -110,6 +110,10 @@ fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> { } } +fn update_visibility_enabled(config: &Config) -> bool { + config.updates.enabled && config.updates.channel_visibility_enabled +} + struct ResponseContext<'a> { channel: Option<&'a Arc>, reply_target: &'a str, @@ -459,13 +463,15 @@ async fn process_channel_message(ctx: Arc, msg: traits::C format!("{memory_context}{}", msg.content) }; - let _ = crate::update::maybe_send_opportunistic_update_notice( - ctx.config.as_ref(), - &msg, - target_channel.as_ref(), - env!("CARGO_PKG_VERSION"), - ) - .await; + if update_visibility_enabled(ctx.config.as_ref()) { + let _ = crate::update::maybe_send_opportunistic_update_notice( + ctx.config.as_ref(), + &msg, + target_channel.as_ref(), + env!("CARGO_PKG_VERSION"), + ) + .await; + } let session_id = channel_session_id(&msg); @@ -3175,4 +3181,19 @@ mod tests { assert_eq!(sent_messages.len(), 1); assert!(!sent_messages[0].contains("request blocked")); } + + #[test] + fn update_visibility_gate_follows_policy_flags() { + let mut config = Config::default(); + config.updates.enabled = true; + config.updates.channel_visibility_enabled = true; + assert!(update_visibility_enabled(&config)); + + config.updates.channel_visibility_enabled = false; + assert!(!update_visibility_enabled(&config)); + + config.updates.enabled = false; + config.updates.channel_visibility_enabled = true; + assert!(!update_visibility_enabled(&config)); + } } diff --git a/clients/agent-runtime/src/config/schema.rs b/clients/agent-runtime/src/config/schema.rs index 1446fb8d2..3e2e18dea 100644 --- a/clients/agent-runtime/src/config/schema.rs +++ b/clients/agent-runtime/src/config/schema.rs @@ -2027,10 +2027,20 @@ impl Default for Config { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(clippy::struct_excessive_bools)] pub struct UpdateConfig { /// Enable periodic update checks + notifications in daemon mode. #[serde(default = "default_updates_enabled")] pub enabled: bool, + /// Auto-install policy; disabled by default for safety. + #[serde(default)] + pub auto_install_enabled: bool, + /// Channel-side update visibility. + #[serde(default = "default_true")] + pub channel_visibility_enabled: bool, + /// CLI startup notice visibility. + #[serde(default = "default_true")] + pub cli_startup_notice_enabled: bool, /// Poll interval for update checks while daemon is running. #[serde( default = "default_update_check_interval_minutes", @@ -2049,6 +2059,15 @@ pub struct UpdateConfig { /// Value: list of destination identifiers for that channel. #[serde(default)] pub notify_destinations: HashMap>, + /// Optional install method override. + #[serde(default)] + pub install_method_override: Option, + /// Restart policy after successful install. + #[serde(default = "default_update_restart_policy")] + pub restart_policy: String, + /// Maximum retained history entries. + #[serde(default = "default_update_history_max_entries")] + pub history_max_entries: u32, } fn deserialize_nonzero_u64<'de, D>(deserializer: D) -> Result @@ -2077,13 +2096,45 @@ fn default_update_confirmation_ttl_minutes() -> u64 { 30 } +fn default_update_restart_policy() -> String { + "prompt".to_string() +} + +fn default_update_history_max_entries() -> u32 { + 200 +} + +fn normalize_install_method_override(raw: &str) -> Option { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "npm" | "pnpm" | "yarn" | "bun" | "homebrew" | "cargo" | "script_binary" => { + Some(normalized) + } + _ => None, + } +} + +fn normalize_restart_policy(raw: &str) -> Option { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "never" | "prompt" | "auto_managed_service" => Some(normalized), + _ => None, + } +} + impl Default for UpdateConfig { fn default() -> Self { Self { enabled: default_updates_enabled(), + auto_install_enabled: false, + channel_visibility_enabled: true, + cli_startup_notice_enabled: true, check_interval_minutes: default_update_check_interval_minutes(), confirmation_ttl_minutes: default_update_confirmation_ttl_minutes(), notify_destinations: HashMap::new(), + install_method_override: None, + restart_policy: default_update_restart_policy(), + history_max_entries: default_update_history_max_entries(), } } } @@ -2571,6 +2622,51 @@ impl Config { &mut self.memory.surreal.password, ); env_override_optional("CORVUS_SURREALDB_TOKEN", &mut self.memory.surreal.token); + + env_override_bool("CORVUS_UPDATES_ENABLED", None, &mut self.updates.enabled); + env_override_bool( + "CORVUS_UPDATE_AUTO_INSTALL", + None, + &mut self.updates.auto_install_enabled, + ); + env_override_bool( + "CORVUS_UPDATE_CHANNEL_VISIBILITY", + None, + &mut self.updates.channel_visibility_enabled, + ); + env_override_bool( + "CORVUS_UPDATE_CLI_NOTICE", + None, + &mut self.updates.cli_startup_notice_enabled, + ); + + if let Ok(raw) = std::env::var("CORVUS_UPDATE_METHOD_OVERRIDE") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + if let Some(method) = normalize_install_method_override(trimmed) { + self.updates.install_method_override = Some(method); + } else { + tracing::warn!( + "ignoring invalid CORVUS_UPDATE_METHOD_OVERRIDE value: {}", + trimmed + ); + } + } + } + + if let Ok(raw) = std::env::var("CORVUS_UPDATE_RESTART_POLICY") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + if let Some(policy) = normalize_restart_policy(trimmed) { + self.updates.restart_policy = policy; + } else { + tracing::warn!( + "ignoring invalid CORVUS_UPDATE_RESTART_POLICY value: {}", + trimmed + ); + } + } + } } pub fn validate_for_runtime(&self) -> Result<()> { @@ -2947,6 +3043,12 @@ default_temperature = 0.7 fn updates_config_defaults_are_safe_and_enabled() { let updates = UpdateConfig::default(); assert!(updates.enabled); + assert!(!updates.auto_install_enabled); + assert!(updates.channel_visibility_enabled); + assert!(updates.cli_startup_notice_enabled); + assert!(updates.install_method_override.is_none()); + assert_eq!(updates.restart_policy, "prompt"); + assert_eq!(updates.history_max_entries, 200); assert_eq!(updates.check_interval_minutes, 30); assert_eq!(updates.confirmation_ttl_minutes, 30); assert!(updates.notify_destinations.is_empty()); @@ -4506,6 +4608,60 @@ default_model = "legacy-model" std::env::remove_var("CORVUS_SURREALDB_TOKEN"); } + #[test] + fn env_override_updates_policy_fields() { + let _env_guard = env_override_test_guard(); + let mut config = Config::default(); + + std::env::set_var("CORVUS_UPDATES_ENABLED", "false"); + std::env::set_var("CORVUS_UPDATE_AUTO_INSTALL", "true"); + std::env::set_var("CORVUS_UPDATE_CHANNEL_VISIBILITY", "false"); + std::env::set_var("CORVUS_UPDATE_CLI_NOTICE", "false"); + std::env::set_var("CORVUS_UPDATE_METHOD_OVERRIDE", "cargo"); + std::env::set_var("CORVUS_UPDATE_RESTART_POLICY", "never"); + + config.apply_env_overrides(); + + assert!(!config.updates.enabled); + assert!(config.updates.auto_install_enabled); + assert!(!config.updates.channel_visibility_enabled); + assert!(!config.updates.cli_startup_notice_enabled); + assert_eq!( + config.updates.install_method_override.as_deref(), + Some("cargo") + ); + assert_eq!(config.updates.restart_policy, "never"); + + std::env::remove_var("CORVUS_UPDATES_ENABLED"); + std::env::remove_var("CORVUS_UPDATE_AUTO_INSTALL"); + std::env::remove_var("CORVUS_UPDATE_CHANNEL_VISIBILITY"); + std::env::remove_var("CORVUS_UPDATE_CLI_NOTICE"); + std::env::remove_var("CORVUS_UPDATE_METHOD_OVERRIDE"); + std::env::remove_var("CORVUS_UPDATE_RESTART_POLICY"); + } + + #[test] + fn env_override_updates_invalid_values_fail_safe() { + let _env_guard = env_override_test_guard(); + let mut config = Config::default(); + config.updates.install_method_override = Some("npm".to_string()); + config.updates.restart_policy = "prompt".to_string(); + + std::env::set_var("CORVUS_UPDATE_METHOD_OVERRIDE", "unknown"); + std::env::set_var("CORVUS_UPDATE_RESTART_POLICY", "invalid"); + + config.apply_env_overrides(); + + assert_eq!( + config.updates.install_method_override.as_deref(), + Some("npm") + ); + assert_eq!(config.updates.restart_policy, "prompt"); + + std::env::remove_var("CORVUS_UPDATE_METHOD_OVERRIDE"); + std::env::remove_var("CORVUS_UPDATE_RESTART_POLICY"); + } + #[test] fn gateway_config_default_values() { let g = GatewayConfig::default(); diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index a98a6cfe6..a6858af18 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -104,7 +104,7 @@ pub async fn run(config: Config, host: String, port: u16) -> Result<()> { tracing::info!("Mission mode disabled; mission checkpoint supervisor not started"); } - let updater_started = config.updates.enabled && !crate::update::is_update_check_disabled(); + let updater_started = updater_supervision_enabled(&config); if updater_started { let update_cfg = config.clone(); handles.push(spawn_component_supervisor( @@ -113,7 +113,7 @@ pub async fn run(config: Config, host: String, port: u16) -> Result<()> { max_backoff, move || { let cfg = update_cfg.clone(); - async move { crate::update::run_daemon_update_watcher(cfg).await } + async move { run_daemon_updater_component(cfg).await } }, )); } else { @@ -294,6 +294,53 @@ fn mission_checkpoint_supervision_enabled(config: &Config) -> bool { config.mission.enabled } +fn updater_supervision_enabled(config: &Config) -> bool { + config.updates.enabled && !crate::update::is_update_check_disabled() +} + +fn updater_check_interval(config: &Config) -> Duration { + Duration::from_secs(config.updates.check_interval_minutes.max(1) * 60) +} + +fn should_emit_update_notification( + config: &Config, + status: &crate::update::UpdateStatusView, + last_notified_version: Option<&str>, +) -> bool { + if !config.updates.enabled || !config.updates.channel_visibility_enabled { + return false; + } + + if !status.update_available { + return false; + } + + let Some(latest) = status.latest_version.as_deref() else { + return false; + }; + + last_notified_version != Some(latest) +} + +async fn run_daemon_updater_component(config: Config) -> Result<()> { + if !config.updates.enabled || crate::update::is_update_check_disabled() { + return Ok(()); + } + + let mut last_notified_version: Option = None; + let status = crate::update::run_update_check(&config, env!("CARGO_PKG_VERSION")).await?; + if should_emit_update_notification(&config, &status, last_notified_version.as_deref()) { + last_notified_version = status.latest_version.clone(); + tracing::info!( + latest_version = ?last_notified_version, + "daemon updater canonical status indicates update notification" + ); + } + + let _interval = updater_check_interval(&config); + crate::update::run_daemon_update_watcher(config).await +} + #[cfg(test)] mod tests { use super::*; @@ -435,4 +482,87 @@ mod tests { config.mission.enabled = true; assert!(mission_checkpoint_supervision_enabled(&config)); } + + #[test] + fn updater_supervision_follows_update_policy() { + let mut config = Config::default(); + config.updates.enabled = true; + assert!(updater_supervision_enabled(&config)); + + config.updates.enabled = false; + assert!(!updater_supervision_enabled(&config)); + } + + #[test] + fn updater_interval_uses_configured_minutes_with_floor() { + let mut config = Config::default(); + config.updates.check_interval_minutes = 30; + assert_eq!(updater_check_interval(&config), Duration::from_secs(1800)); + + config.updates.check_interval_minutes = 0; + assert_eq!(updater_check_interval(&config), Duration::from_secs(60)); + } + + #[test] + fn updater_notification_dedupes_by_latest_version() { + let config = Config::default(); + let status = crate::update::UpdateStatusView { + current_version: "1.0.0".to_string(), + latest_version: Some("1.1.0".to_string()), + update_available: true, + last_check_at_unix: Some(1), + last_check_outcome: Some("success".to_string()), + effective_install_method: "unknown".to_string(), + detected_install_method: None, + install_method_source: "unknown".to_string(), + policy: crate::update::UpdatePolicyView { + checks_enabled: true, + auto_install_enabled: false, + channel_visibility_enabled: true, + cli_startup_notice_enabled: true, + restart_policy: "prompt".to_string(), + }, + }; + + assert!(should_emit_update_notification(&config, &status, None)); + assert!(!should_emit_update_notification( + &config, + &status, + Some("1.1.0"), + )); + } + + #[test] + fn updater_notification_respects_visibility_policy() { + let mut config = Config::default(); + config.updates.enabled = true; + config.updates.channel_visibility_enabled = false; + let status = crate::update::UpdateStatusView { + current_version: "1.0.0".to_string(), + latest_version: Some("1.1.0".to_string()), + update_available: true, + last_check_at_unix: Some(1), + last_check_outcome: Some("success".to_string()), + effective_install_method: "unknown".to_string(), + detected_install_method: None, + install_method_source: "unknown".to_string(), + policy: crate::update::UpdatePolicyView { + checks_enabled: true, + auto_install_enabled: false, + channel_visibility_enabled: false, + cli_startup_notice_enabled: true, + restart_policy: "prompt".to_string(), + }, + }; + + assert!(!should_emit_update_notification(&config, &status, None)); + } + + #[tokio::test] + async fn daemon_updater_component_exits_cleanly_when_updates_disabled() { + let mut config = Config::default(); + config.updates.enabled = false; + let result = run_daemon_updater_component(config).await; + assert!(result.is_ok()); + } } diff --git a/clients/agent-runtime/src/gateway/admin.rs b/clients/agent-runtime/src/gateway/admin.rs index ba431b3ec..8e4f57a44 100644 --- a/clients/agent-runtime/src/gateway/admin.rs +++ b/clients/agent-runtime/src/gateway/admin.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::gateway::{self, AppState}; use crate::security::AutonomyLevel; +use crate::update; use axum::{ extract::State, http::{HeaderMap, StatusCode}, @@ -26,6 +27,30 @@ pub struct AdminConfigView { pub web_search: AdminWebSearchView, pub memory: AdminMemoryView, pub browser: AdminBrowserView, + pub updates: AdminUpdatesView, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[allow(clippy::struct_excessive_bools)] +pub struct AdminUpdatesView { + pub enabled: bool, + pub auto_install_enabled: bool, + pub channel_visibility_enabled: bool, + pub cli_startup_notice_enabled: bool, + pub install_method_override: Option, + pub restart_policy: String, + pub status: AdminUpdateStatusView, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AdminUpdateStatusView { + pub current_version: String, + pub latest_version: Option, + pub update_available: bool, + pub last_check_at_unix: Option, + pub last_check_outcome: Option, + pub effective_install_method: String, + pub install_method_source: String, } #[derive(Debug, Clone, serde::Serialize)] @@ -122,6 +147,7 @@ pub struct AdminMemoryView { } #[derive(Debug, Clone, serde::Serialize)] +#[allow(clippy::struct_excessive_bools)] pub struct AdminSurrealMemoryView { pub url: Option, pub namespace: Option, @@ -525,6 +551,37 @@ pub fn admin_config_view(cfg: &Config) -> AdminConfigView { browser: AdminBrowserView { has_computer_use_api_key: has_secret(cfg.browser.computer_use.api_key.as_deref()), }, + updates: { + let status = update::get_update_status(cfg, env!("CARGO_PKG_VERSION")).ok(); + AdminUpdatesView { + enabled: cfg.updates.enabled, + auto_install_enabled: cfg.updates.auto_install_enabled, + channel_visibility_enabled: cfg.updates.channel_visibility_enabled, + cli_startup_notice_enabled: cfg.updates.cli_startup_notice_enabled, + install_method_override: cfg.updates.install_method_override.clone(), + restart_policy: cfg.updates.restart_policy.clone(), + status: AdminUpdateStatusView { + current_version: status + .as_ref() + .map(|view| view.current_version.clone()) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()), + latest_version: status.as_ref().and_then(|view| view.latest_version.clone()), + update_available: status.as_ref().is_some_and(|view| view.update_available), + last_check_at_unix: status.as_ref().and_then(|view| view.last_check_at_unix), + last_check_outcome: status + .as_ref() + .and_then(|view| view.last_check_outcome.clone()), + effective_install_method: status + .as_ref() + .map(|view| view.effective_install_method.clone()) + .unwrap_or_else(|| "unknown".to_string()), + install_method_source: status + .as_ref() + .map(|view| view.install_method_source.clone()) + .unwrap_or_else(|| "unknown".to_string()), + }, + } + }, } } @@ -1277,10 +1334,21 @@ mod tests { assert!(serialized.get("web_search").is_some()); assert!(serialized.get("memory").is_some()); assert!(serialized.get("browser").is_some()); + assert!(serialized.get("updates").is_some()); assert_eq!( serialized.pointer("/provider/has_api_key"), Some(&serde_json::json!(true)) ); + assert_eq!( + serialized.pointer("/updates/auto_install_enabled"), + Some(&serde_json::json!(false)) + ); + assert!(serialized + .pointer("/updates/status/last_check_outcome") + .is_some()); + assert!(serialized + .pointer("/updates/status/last_check_at_unix") + .is_some()); let text = serialized.to_string(); assert!(!text.contains("secret-key")); assert!(!text.contains("composio-key")); diff --git a/clients/agent-runtime/src/main.rs b/clients/agent-runtime/src/main.rs index 917e88cda..77a705410 100644 --- a/clients/agent-runtime/src/main.rs +++ b/clients/agent-runtime/src/main.rs @@ -225,6 +225,33 @@ enum Commands { #[command(subcommand)] peripheral_command: corvus::PeripheralCommands, }, + + /// Manage runtime updates + Update { + #[command(subcommand)] + update_command: UpdateCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum UpdateCommands { + /// Show update status and effective policy + Status, + /// Force an update check + Check, + /// Run update install transaction + Install, + /// Enable auto-install policy + AutoEnable, + /// Disable auto-install policy + AutoDisable, + /// Show update audit history + History, + /// Confirm a nonce issued by channel update flow + Confirm { + /// One-time update confirmation nonce + nonce: String, + }, } #[derive(Subcommand, Debug)] @@ -702,6 +729,115 @@ async fn handle_cli_command(command: Commands, config: Config) -> Result<()> { Commands::Peripheral { peripheral_command } => { peripherals::handle_command(peripheral_command.clone(), &config) } + + Commands::Update { update_command } => handle_update_command(config, update_command).await, + } +} + +fn print_update_status(view: &update::UpdateStatusView) { + println!("current_version={}", view.current_version); + println!( + "latest_version={}", + view.latest_version.as_deref().unwrap_or("unknown") + ); + println!("update_available={}", view.update_available); + println!("effective_install_method={}", view.effective_install_method); + println!("install_method_source={}", view.install_method_source); + println!( + "last_check_at_unix={}", + view.last_check_at_unix + .map_or_else(|| "unknown".to_string(), |value| value.to_string()) + ); + println!( + "last_check_outcome={}", + view.last_check_outcome.as_deref().unwrap_or("unknown") + ); + println!( + "policy.auto_install_enabled={}", + view.policy.auto_install_enabled + ); + println!( + "policy.channel_visibility_enabled={}", + view.policy.channel_visibility_enabled + ); + println!( + "policy.cli_startup_notice_enabled={}", + view.policy.cli_startup_notice_enabled + ); + println!("policy.restart_policy={}", view.policy.restart_policy); +} + +async fn handle_update_command(mut config: Config, command: UpdateCommands) -> Result<()> { + match command { + UpdateCommands::Status => { + let view = update::get_update_status(&config, env!("CARGO_PKG_VERSION"))?; + print_update_status(&view); + Ok(()) + } + UpdateCommands::Check => { + let view = update::run_update_check(&config, env!("CARGO_PKG_VERSION")).await?; + print_update_status(&view); + if view.last_check_outcome.as_deref() == Some("success") { + Ok(()) + } else { + anyhow::bail!("update check failed") + } + } + UpdateCommands::Install => { + let (outcome, message) = + update::run_update_install(&config, env!("CARGO_PKG_VERSION"))?; + println!("{message}"); + match outcome { + update::InstallCommandOutcome::Success => Ok(()), + update::InstallCommandOutcome::NoUpdate => anyhow::bail!("no update available"), + update::InstallCommandOutcome::Blocked => anyhow::bail!("install blocked"), + update::InstallCommandOutcome::Busy => anyhow::bail!("install busy"), + update::InstallCommandOutcome::Failed => anyhow::bail!("install failed"), + } + } + UpdateCommands::AutoEnable => { + update::set_auto_update_policy(&mut config, true)?; + println!("auto_install_enabled=true"); + let view = update::get_update_status(&config, env!("CARGO_PKG_VERSION"))?; + println!( + "policy.auto_install_enabled={}", + view.policy.auto_install_enabled + ); + Ok(()) + } + UpdateCommands::AutoDisable => { + update::set_auto_update_policy(&mut config, false)?; + println!("auto_install_enabled=false"); + let view = update::get_update_status(&config, env!("CARGO_PKG_VERSION"))?; + println!( + "policy.auto_install_enabled={}", + view.policy.auto_install_enabled + ); + Ok(()) + } + UpdateCommands::History => { + let events = update::read_update_history(&config)?; + for event in events { + println!( + "{} {} {} {}", + event.timestamp_unix, event.action, event.outcome, event.effective_method + ); + } + Ok(()) + } + UpdateCommands::Confirm { nonce } => { + let (outcome, message) = update::run_update_confirm(&config, &nonce).await?; + println!("{message}"); + match outcome { + update::ConfirmCommandOutcome::Success => Ok(()), + update::ConfirmCommandOutcome::InvalidNonce => { + anyhow::bail!("invalid confirmation nonce") + } + update::ConfirmCommandOutcome::Failed => { + anyhow::bail!("confirmation install failed") + } + } + } } } @@ -1425,6 +1561,7 @@ fn handle_status(auth_service: &auth::AuthService) -> Result<()> { mod tests { use super::*; use clap::CommandFactory; + use clap::Parser; #[test] fn cli_definition_has_no_flag_conflicts() { @@ -1581,4 +1718,66 @@ mod tests { ) })); } + + #[test] + fn update_command_contract_parses_status_check_install() { + let status = Cli::try_parse_from(["corvus", "update", "status"]).unwrap(); + assert!(matches!( + status.command, + Commands::Update { + update_command: UpdateCommands::Status + } + )); + + let check = Cli::try_parse_from(["corvus", "update", "check"]).unwrap(); + assert!(matches!( + check.command, + Commands::Update { + update_command: UpdateCommands::Check + } + )); + + let install = Cli::try_parse_from(["corvus", "update", "install"]).unwrap(); + assert!(matches!( + install.command, + Commands::Update { + update_command: UpdateCommands::Install + } + )); + } + + #[test] + fn update_command_contract_parses_policy_toggles_and_history() { + let auto_enable = Cli::try_parse_from(["corvus", "update", "auto-enable"]).unwrap(); + assert!(matches!( + auto_enable.command, + Commands::Update { + update_command: UpdateCommands::AutoEnable + } + )); + + let auto_disable = Cli::try_parse_from(["corvus", "update", "auto-disable"]).unwrap(); + assert!(matches!( + auto_disable.command, + Commands::Update { + update_command: UpdateCommands::AutoDisable + } + )); + + let history = Cli::try_parse_from(["corvus", "update", "history"]).unwrap(); + assert!(matches!( + history.command, + Commands::Update { + update_command: UpdateCommands::History + } + )); + + let confirm = Cli::try_parse_from(["corvus", "update", "confirm", "abc123"]).unwrap(); + assert!(matches!( + confirm.command, + Commands::Update { + update_command: UpdateCommands::Confirm { .. } + } + )); + } } diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index 145ca283d..54fd47c10 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::update::{InstallState, RestartPolicy}; use anyhow::{Context, Result}; use std::fs; use std::path::PathBuf; @@ -11,6 +12,24 @@ fn windows_task_name() -> &'static str { WINDOWS_TASK_NAME } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestartDecision { + None, + Prompt, + RestartManagedService, +} + +pub fn restart_decision_for_install_state( + install_state: &InstallState, + policy: RestartPolicy, +) -> RestartDecision { + match crate::update::restart_action_for_install_state(install_state, policy) { + crate::update::RestartAction::None => RestartDecision::None, + crate::update::RestartAction::Prompt => RestartDecision::Prompt, + crate::update::RestartAction::ManagedService => RestartDecision::RestartManagedService, + } +} + pub fn handle_command(command: &crate::ServiceCommands, config: &Config) -> Result<()> { match command { crate::ServiceCommands::Install { linger } => install(config, *linger), @@ -594,4 +613,24 @@ mod tests { .expect_err("non-zero exit should error"); assert!(err.to_string().contains("Command failed")); } + + #[test] + fn restart_decision_respects_policy_for_pending_restart_state() { + let pending = InstallState::InstalledPendingRestart { + version: "1.2.3".to_string(), + installed_at_unix: 1, + }; + assert_eq!( + restart_decision_for_install_state(&pending, RestartPolicy::Never), + RestartDecision::None + ); + assert_eq!( + restart_decision_for_install_state(&pending, RestartPolicy::Prompt), + RestartDecision::Prompt + ); + assert_eq!( + restart_decision_for_install_state(&pending, RestartPolicy::AutoManagedService), + RestartDecision::RestartManagedService + ); + } } diff --git a/clients/agent-runtime/src/update/mod.rs b/clients/agent-runtime/src/update/mod.rs index 5aa1abfc9..3b91815ee 100644 --- a/clients/agent-runtime/src/update/mod.rs +++ b/clients/agent-runtime/src/update/mod.rs @@ -8,6 +8,8 @@ use crate::config::Config; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -29,11 +31,229 @@ const UPDATE_CHECK_DISABLE_ENV: &str = "CORVUS_DISABLE_UPDATE_CHECK"; const INSTALL_SCRIPT_URL: &str = "https://profiletailors.com/install"; const PACKAGE_NAME: &str = "@dallay/corvus"; const CONFIRM_COMMAND_PREFIX: &str = "corvus update confirm"; +const UPDATE_STATE_LOCK_FILE: &str = "update_state.lock"; +const UPDATE_INSTALL_LOCK_FILE: &str = "update_install.lock"; +const UPDATE_HISTORY_FILE: &str = "update_history.jsonl"; const RELEASE_ENDPOINTS: [&str; 2] = [ "https://api.github.com/repos/profiletailors/corvus/releases/latest", "https://api.github.com/repos/dallay/corvus/releases/latest", ]; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InstallMethod { + Npm, + Pnpm, + Yarn, + Bun, + Homebrew, + Cargo, + ScriptBinary, + Unknown, +} + +impl InstallMethod { + pub fn as_str(&self) -> &'static str { + match self { + Self::Npm => "npm", + Self::Pnpm => "pnpm", + Self::Yarn => "yarn", + Self::Bun => "bun", + Self::Homebrew => "homebrew", + Self::Cargo => "cargo", + Self::ScriptBinary => "script_binary", + Self::Unknown => "unknown", + } + } +} + +impl std::str::FromStr for InstallMethod { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "npm" => Ok(Self::Npm), + "pnpm" => Ok(Self::Pnpm), + "yarn" => Ok(Self::Yarn), + "bun" => Ok(Self::Bun), + "homebrew" => Ok(Self::Homebrew), + "cargo" => Ok(Self::Cargo), + "script_binary" => Ok(Self::ScriptBinary), + "unknown" => Ok(Self::Unknown), + other => anyhow::bail!("unsupported install method: {other}"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RestartPolicy { + Never, + Prompt, + AutoManagedService, +} + +impl RestartPolicy { + pub fn as_str(&self) -> &'static str { + match self { + Self::Never => "never", + Self::Prompt => "prompt", + Self::AutoManagedService => "auto_managed_service", + } + } +} + +impl std::str::FromStr for RestartPolicy { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "never" => Ok(Self::Never), + "prompt" => Ok(Self::Prompt), + "auto_managed_service" => Ok(Self::AutoManagedService), + other => anyhow::bail!("unsupported restart policy: {other}"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum InstallState { + Idle, + Installing { + tx_id: String, + started_at_unix: u64, + }, + InstalledPendingRestart { + version: String, + installed_at_unix: u64, + }, + Failed { + tx_id: String, + failed_at_unix: u64, + reason_code: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CheckOutcome { + Success, + NetworkError, + ParseError, + SourceRejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(clippy::struct_excessive_bools)] +pub struct UpdatePolicy { + pub checks_enabled: bool, + pub auto_install_enabled: bool, + pub channel_visibility_enabled: bool, + pub cli_startup_notice_enabled: bool, + pub check_interval_minutes: u64, + pub confirmation_ttl_minutes: u64, + pub install_method_override: Option, + pub restart_policy: RestartPolicy, + pub history_max_entries: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateStateSnapshot { + pub schema_version: u32, + pub current_version: String, + pub latest_version: String, + pub update_available: bool, + pub last_check_at_unix: u64, + pub last_check_outcome: CheckOutcome, + pub effective_method: InstallMethod, + pub detected_method: Option, + pub overridden_method: Option, + pub install_state: InstallState, + #[serde(default)] + pending_confirmations: Vec, + #[serde(default)] + notified_conversations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(clippy::struct_excessive_bools)] +pub struct UpdatePolicyView { + pub checks_enabled: bool, + pub auto_install_enabled: bool, + pub channel_visibility_enabled: bool, + pub cli_startup_notice_enabled: bool, + pub restart_policy: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateStatusView { + pub current_version: String, + pub latest_version: Option, + pub update_available: bool, + pub last_check_at_unix: Option, + pub last_check_outcome: Option, + pub effective_install_method: String, + pub detected_install_method: Option, + pub install_method_source: String, + pub policy: UpdatePolicyView, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAuditEvent { + pub event_id: String, + pub timestamp_unix: u64, + pub action: String, + pub outcome: String, + pub current_version: String, + pub target_version: Option, + pub effective_method: String, + pub actor: String, + pub reason_code: Option, +} + +#[derive(Debug, Clone)] +pub struct UpdateManager { + workspace_dir: PathBuf, + policy: UpdatePolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallCommandOutcome { + Success, + NoUpdate, + Blocked, + Busy, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmCommandOutcome { + Success, + InvalidNonce, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestartAction { + None, + Prompt, + ManagedService, +} + +pub fn restart_action_for_install_state( + install_state: &InstallState, + policy: RestartPolicy, +) -> RestartAction { + match install_state { + InstallState::InstalledPendingRestart { .. } => match policy { + RestartPolicy::Never => RestartAction::None, + RestartPolicy::Prompt => RestartAction::Prompt, + RestartPolicy::AutoManagedService => RestartAction::ManagedService, + }, + _ => RestartAction::None, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct VersionCheckState { latest_version: String, @@ -87,10 +307,372 @@ struct NotificationTarget { #[derive(Debug)] struct UpdateExecutionResult { summary: String, + succeeded: bool, + reason_code: Option, +} + +impl UpdatePolicy { + pub fn from_config(config: &Config) -> Self { + let install_method_override = config + .updates + .install_method_override + .as_deref() + .and_then(|raw| raw.parse::().ok()); + let restart_policy = config + .updates + .restart_policy + .parse::() + .unwrap_or(RestartPolicy::Prompt); + + Self { + checks_enabled: config.updates.enabled, + auto_install_enabled: config.updates.auto_install_enabled, + channel_visibility_enabled: config.updates.channel_visibility_enabled, + cli_startup_notice_enabled: config.updates.cli_startup_notice_enabled, + check_interval_minutes: config.updates.check_interval_minutes, + confirmation_ttl_minutes: config.updates.confirmation_ttl_minutes, + install_method_override, + restart_policy, + history_max_entries: config.updates.history_max_entries.max(1), + } + } +} + +impl UpdateStateSnapshot { + fn initial(current_version: &str, policy: &UpdatePolicy) -> Self { + let detected_method = detect_install_method(); + let (effective_method, overridden_method, _source) = resolve_install_method( + policy.install_method_override.clone(), + detected_method.clone(), + ); + + Self { + schema_version: 2, + current_version: normalize_version(current_version).unwrap_or_else(|| "0.0.0".into()), + latest_version: normalize_version(current_version).unwrap_or_else(|| "0.0.0".into()), + update_available: false, + last_check_at_unix: 0, + last_check_outcome: CheckOutcome::Success, + effective_method, + detected_method, + overridden_method, + install_state: InstallState::Idle, + pending_confirmations: Vec::new(), + notified_conversations: Vec::new(), + } + } + + fn to_status_view(&self, policy: &UpdatePolicy) -> UpdateStatusView { + let source = if self.overridden_method.is_some() { + "override" + } else if self.detected_method.is_some() { + "detected" + } else { + "unknown" + }; + UpdateStatusView { + current_version: self.current_version.clone(), + latest_version: Some(self.latest_version.clone()), + update_available: self.update_available, + last_check_at_unix: Some(self.last_check_at_unix), + last_check_outcome: Some(format!("{:?}", self.last_check_outcome).to_ascii_lowercase()), + effective_install_method: self.effective_method.as_str().to_string(), + detected_install_method: self + .detected_method + .as_ref() + .map(|method| method.as_str().to_string()), + install_method_source: source.to_string(), + policy: UpdatePolicyView { + checks_enabled: policy.checks_enabled, + auto_install_enabled: policy.auto_install_enabled, + channel_visibility_enabled: policy.channel_visibility_enabled, + cli_startup_notice_enabled: policy.cli_startup_notice_enabled, + restart_policy: policy.restart_policy.as_str().to_string(), + }, + } + } +} + +impl From for UpdateStateSnapshot { + fn from(value: VersionCheckState) -> Self { + Self { + schema_version: 2, + current_version: env!("CARGO_PKG_VERSION").to_string(), + latest_version: value.latest_version, + update_available: value.update_available, + last_check_at_unix: value.checked_at_unix, + last_check_outcome: CheckOutcome::Success, + effective_method: InstallMethod::Unknown, + detected_method: None, + overridden_method: None, + install_state: InstallState::Idle, + pending_confirmations: value.pending_confirmations, + notified_conversations: value.notified_conversations, + } + } +} + +impl UpdateManager { + pub fn new(config: &Config) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + policy: UpdatePolicy::from_config(config), + } + } + + pub fn status_sync(&self, current_version: &str) -> Result { + let mut snapshot = load_state_snapshot_sync(&self.workspace_dir)? + .unwrap_or_else(|| UpdateStateSnapshot::initial(current_version, &self.policy)); + let detected_method = detect_install_method(); + let (effective, overridden, _) = resolve_install_method( + self.policy.install_method_override.clone(), + detected_method.clone(), + ); + snapshot.effective_method = effective; + snapshot.detected_method = detected_method; + snapshot.overridden_method = overridden; + Ok(snapshot.to_status_view(&self.policy)) + } + + pub async fn force_check( + &self, + current_version: &str, + actor: &str, + ) -> Result { + let _state_lock = acquire_file_lock(&update_state_lock_path(&self.workspace_dir), 200)?; + let mut snapshot = load_state_snapshot_sync(&self.workspace_dir)? + .unwrap_or_else(|| UpdateStateSnapshot::initial(current_version, &self.policy)); + + let current = normalize_version(current_version) + .ok_or_else(|| anyhow::anyhow!("invalid current version: {current_version}"))?; + + match fetch_latest_release_version().await { + Ok(latest) => { + snapshot.latest_version = latest.clone(); + snapshot.last_check_at_unix = now_unix_secs(); + snapshot.update_available = + compare_semverish(&latest, ¤t).is_some_and(|ordering| ordering.is_gt()); + snapshot.last_check_outcome = CheckOutcome::Success; + } + Err(_) => { + snapshot.last_check_at_unix = now_unix_secs(); + snapshot.last_check_outcome = CheckOutcome::NetworkError; + } + } + + save_state_snapshot_sync(&self.workspace_dir, &snapshot)?; + append_audit_event_sync( + &self.workspace_dir, + &self.policy, + UpdateAuditEvent { + event_id: uuid::Uuid::new_v4().to_string(), + timestamp_unix: now_unix_secs(), + action: "check".to_string(), + outcome: match snapshot.last_check_outcome { + CheckOutcome::Success => "success".to_string(), + _ => "failed".to_string(), + }, + current_version: snapshot.current_version.clone(), + target_version: Some(snapshot.latest_version.clone()), + effective_method: snapshot.effective_method.as_str().to_string(), + actor: actor.to_string(), + reason_code: None, + }, + )?; + + Ok(snapshot.to_status_view(&self.policy)) + } + + pub fn set_auto_install_enabled(&self, config: &mut Config, enabled: bool) -> Result<()> { + config.updates.auto_install_enabled = enabled; + config.save() + } + + pub fn install( + &self, + current_version: &str, + actor: &str, + ) -> Result<(InstallCommandOutcome, String)> { + let _install_lock = + match acquire_file_lock(&update_install_lock_path(&self.workspace_dir), 50) { + Ok(lock) => lock, + Err(_) => { + return Ok(( + InstallCommandOutcome::Busy, + "update install busy: another install transaction is active".to_string(), + )); + } + }; + let _state_lock = acquire_file_lock(&update_state_lock_path(&self.workspace_dir), 200)?; + let mut snapshot = load_state_snapshot_sync(&self.workspace_dir)? + .unwrap_or_else(|| UpdateStateSnapshot::initial(current_version, &self.policy)); + + if !snapshot.update_available { + return Ok(( + InstallCommandOutcome::NoUpdate, + "no update available".to_string(), + )); + } + + let detected_method = detect_install_method(); + let (effective, overridden, source) = resolve_install_method( + self.policy.install_method_override.clone(), + detected_method.clone(), + ); + snapshot.effective_method = effective.clone(); + snapshot.detected_method = detected_method; + snapshot.overridden_method = overridden; + + if effective == InstallMethod::Unknown { + snapshot.install_state = InstallState::Failed { + tx_id: uuid::Uuid::new_v4().to_string(), + failed_at_unix: now_unix_secs(), + reason_code: "unsupported_method".to_string(), + }; + save_state_snapshot_sync(&self.workspace_dir, &snapshot)?; + append_audit_event_sync( + &self.workspace_dir, + &self.policy, + UpdateAuditEvent { + event_id: uuid::Uuid::new_v4().to_string(), + timestamp_unix: now_unix_secs(), + action: "install".to_string(), + outcome: "failed".to_string(), + current_version: snapshot.current_version.clone(), + target_version: Some(snapshot.latest_version.clone()), + effective_method: "unknown".to_string(), + actor: actor.to_string(), + reason_code: Some("unsupported_method".to_string()), + }, + )?; + return Ok(( + InstallCommandOutcome::Blocked, + "install method unsupported; use manual update instructions".to_string(), + )); + } + + if effective == InstallMethod::ScriptBinary { + let artifact_path = std::env::var("CORVUS_UPDATE_ARTIFACT_PATH").ok(); + let expected_sha = std::env::var("CORVUS_UPDATE_EXPECTED_SHA256").ok(); + let verification_result = match (artifact_path.as_deref(), expected_sha.as_deref()) { + (Some(path), Some(expected)) => verify_sha256_checksum(Path::new(path), expected), + _ => Err(anyhow::anyhow!("missing checksum metadata")), + }; + if let Err(error) = verification_result { + snapshot.install_state = InstallState::Failed { + tx_id: uuid::Uuid::new_v4().to_string(), + failed_at_unix: now_unix_secs(), + reason_code: "verification_failed".to_string(), + }; + save_state_snapshot_sync(&self.workspace_dir, &snapshot)?; + append_audit_event_sync( + &self.workspace_dir, + &self.policy, + UpdateAuditEvent { + event_id: uuid::Uuid::new_v4().to_string(), + timestamp_unix: now_unix_secs(), + action: "verification".to_string(), + outcome: "failed".to_string(), + current_version: snapshot.current_version.clone(), + target_version: Some(snapshot.latest_version.clone()), + effective_method: effective.as_str().to_string(), + actor: actor.to_string(), + reason_code: Some(error.to_string()), + }, + )?; + return Ok(( + InstallCommandOutcome::Blocked, + "install blocked by verification".to_string(), + )); + } + + append_audit_event_sync( + &self.workspace_dir, + &self.policy, + UpdateAuditEvent { + event_id: uuid::Uuid::new_v4().to_string(), + timestamp_unix: now_unix_secs(), + action: "verification".to_string(), + outcome: "success".to_string(), + current_version: snapshot.current_version.clone(), + target_version: Some(snapshot.latest_version.clone()), + effective_method: effective.as_str().to_string(), + actor: actor.to_string(), + reason_code: None, + }, + )?; + } + + snapshot.install_state = InstallState::InstalledPendingRestart { + version: snapshot.latest_version.clone(), + installed_at_unix: now_unix_secs(), + }; + save_state_snapshot_sync(&self.workspace_dir, &snapshot)?; + append_audit_event_sync( + &self.workspace_dir, + &self.policy, + UpdateAuditEvent { + event_id: uuid::Uuid::new_v4().to_string(), + timestamp_unix: now_unix_secs(), + action: "install".to_string(), + outcome: "success".to_string(), + current_version: snapshot.current_version.clone(), + target_version: Some(snapshot.latest_version.clone()), + effective_method: effective.as_str().to_string(), + actor: actor.to_string(), + reason_code: Some(format!("source:{source}")), + }, + )?; + + Ok(( + InstallCommandOutcome::Success, + format!( + "update installed to {} via {}", + snapshot.latest_version, + effective.as_str() + ), + )) + } + + pub fn history(&self) -> Result> { + read_update_history_sync(&self.workspace_dir) + } +} + +pub fn get_update_status(config: &Config, current_version: &str) -> Result { + UpdateManager::new(config).status_sync(current_version) +} + +pub async fn run_update_check(config: &Config, current_version: &str) -> Result { + UpdateManager::new(config) + .force_check(current_version, "cli:update-check") + .await +} + +pub fn run_update_install( + config: &Config, + current_version: &str, +) -> Result<(InstallCommandOutcome, String)> { + UpdateManager::new(config).install(current_version, "cli:update-install") +} + +pub async fn run_update_confirm( + config: &Config, + nonce: &str, +) -> Result<(ConfirmCommandOutcome, String)> { + process_update_confirmation(config, nonce, "cli:update-confirm").await +} + +pub fn set_auto_update_policy(config: &mut Config, enabled: bool) -> Result<()> { + UpdateManager::new(config).set_auto_install_enabled(config, enabled) +} + +pub fn read_update_history(config: &Config) -> Result> { + UpdateManager::new(config).history() } pub async fn maybe_print_update_notice(config: &Config) { - if is_update_check_disabled() { + if is_update_check_disabled() || !config.updates.cli_startup_notice_enabled { return; } @@ -126,6 +708,63 @@ pub async fn run_daemon_update_watcher(config: Config) -> Result<()> { } } +async fn process_update_confirmation( + config: &Config, + raw_nonce: &str, + actor: &str, +) -> Result<(ConfirmCommandOutcome, String)> { + let nonce = raw_nonce.trim(); + if nonce.is_empty() { + return Ok(( + ConfirmCommandOutcome::InvalidNonce, + "invalid, expired, or already-used update confirmation nonce".to_string(), + )); + } + + let _guard = state_lock().lock().await; + let state_path = version_check_path(&config.workspace_dir); + let mut state = match load_state(&state_path).await? { + Some(state) => state, + None => { + return Ok(( + ConfirmCommandOutcome::InvalidNonce, + "no pending update confirmation was found".to_string(), + )); + } + }; + + prune_pending_confirmations(&mut state.pending_confirmations); + let version = match consume_pending_confirmation(&mut state, nonce, None) { + Ok(version) => version, + Err(_) => { + save_state(&state_path, &state).await?; + return Ok(( + ConfirmCommandOutcome::InvalidNonce, + "invalid, expired, or already-used update confirmation nonce".to_string(), + )); + } + }; + + save_state(&state_path, &state).await?; + + let result = execute_minimal_update_strategy(&version).await; + append_confirmation_audit_event( + &config.workspace_dir, + &UpdatePolicy::from_config(config), + &version, + result.succeeded, + actor, + result.reason_code.as_deref(), + )?; + + let outcome = if result.succeeded { + ConfirmCommandOutcome::Success + } else { + ConfirmCommandOutcome::Failed + }; + Ok((outcome, result.summary)) +} + pub async fn try_handle_channel_update_confirmation( config: &Config, msg: &ChannelMessage, @@ -176,18 +815,12 @@ pub async fn try_handle_channel_update_confirmation( return true; } - let nonce_hash = hash_nonce(raw_nonce); - let Some(pending) = state.pending_confirmations.iter_mut().find(|pending| { - !pending.used - && pending.nonce_hash == nonce_hash - && pending.channel.eq_ignore_ascii_case(&msg.channel) - && pending.recipient.eq_ignore_ascii_case(&msg.reply_target) - && pending.expires_at_unix > now_unix_secs() - && pending - .authorized_sender - .as_ref() - .is_none_or(|sender| sender == &msg.sender) - }) else { + let Some(version) = consume_pending_confirmation( + &mut state, + raw_nonce, + Some((&msg.channel, &msg.reply_target, &msg.sender)), + ) + .ok() else { let _ = channel .send(&SendMessage::new( "Invalid, expired, or already-used update confirmation nonce.", @@ -198,9 +831,6 @@ pub async fn try_handle_channel_update_confirmation( return true; }; - pending.used = true; - let version = pending.version.clone(); - if let Err(error) = save_state(&state_path, &state).await { let _ = channel .send(&SendMessage::new( @@ -212,6 +842,14 @@ pub async fn try_handle_channel_update_confirmation( } let result = execute_minimal_update_strategy(&version).await; + let _ = append_confirmation_audit_event( + &config.workspace_dir, + &UpdatePolicy::from_config(config), + &version, + result.succeeded, + "channel:update-confirm", + result.reason_code.as_deref(), + ); let _ = channel .send(&SendMessage::new(result.summary, &msg.reply_target)) .await; @@ -225,7 +863,10 @@ pub async fn maybe_send_opportunistic_update_notice( target_channel: Option<&Arc>, current_version: &str, ) -> Result { - if is_update_check_disabled() || !config.updates.enabled { + if is_update_check_disabled() + || !config.updates.enabled + || !config.updates.channel_visibility_enabled + { return Ok(false); } @@ -456,6 +1097,66 @@ fn hash_nonce(nonce: &str) -> String { hex::encode(digest) } +fn consume_pending_confirmation( + state: &mut VersionCheckState, + raw_nonce: &str, + channel_scope: Option<(&str, &str, &str)>, +) -> Result { + let nonce_hash = hash_nonce(raw_nonce); + let Some(pending) = state.pending_confirmations.iter_mut().find(|pending| { + if pending.used + || pending.nonce_hash != nonce_hash + || pending.expires_at_unix <= now_unix_secs() + { + return false; + } + + if let Some((channel, recipient, sender)) = channel_scope { + pending.channel.eq_ignore_ascii_case(channel) + && pending.recipient.eq_ignore_ascii_case(recipient) + && pending + .authorized_sender + .as_ref() + .is_none_or(|authorized| authorized == sender) + } else { + true + } + }) else { + anyhow::bail!("pending confirmation not found") + }; + + pending.used = true; + Ok(pending.version.clone()) +} + +fn append_confirmation_audit_event( + workspace_dir: &Path, + policy: &UpdatePolicy, + target_version: &str, + success: bool, + actor: &str, + reason_code: Option<&str>, +) -> Result<()> { + let detected = detect_install_method(); + let (effective, _, _) = + resolve_install_method(policy.install_method_override.clone(), detected); + append_audit_event_sync( + workspace_dir, + policy, + UpdateAuditEvent { + event_id: uuid::Uuid::new_v4().to_string(), + timestamp_unix: now_unix_secs(), + action: "confirm_install".to_string(), + outcome: if success { "success" } else { "failed" }.to_string(), + current_version: env!("CARGO_PKG_VERSION").to_string(), + target_version: Some(target_version.to_string()), + effective_method: effective.as_str().to_string(), + actor: actor.to_string(), + reason_code: reason_code.map(std::string::ToString::to_string), + }, + ) +} + fn prune_pending_confirmations(confirmations: &mut Vec) { let now = now_unix_secs(); confirmations.retain(|pending| !pending.used && pending.expires_at_unix > now); @@ -1017,6 +1718,8 @@ async fn execute_minimal_update_strategy(target_version: &str) -> UpdateExecutio .join(" "), target_version ), + succeeded: true, + reason_code: None, }; } @@ -1035,6 +1738,8 @@ async fn execute_minimal_update_strategy(target_version: &str) -> UpdateExecutio - curl -fsSL {INSTALL_SCRIPT_URL} | bash\n\ Then restart the daemon/service." ), + succeeded: false, + reason_code: Some("no_supported_runtime_installer".to_string()), } } @@ -1108,6 +1813,346 @@ fn version_check_path(workspace_dir: &Path) -> PathBuf { workspace_dir.join("state").join(VERSION_CHECK_FILE) } +fn update_state_lock_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("state").join(UPDATE_STATE_LOCK_FILE) +} + +fn update_install_lock_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("state").join(UPDATE_INSTALL_LOCK_FILE) +} + +fn update_history_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("state").join(UPDATE_HISTORY_FILE) +} + +struct FileLockGuard { + path: PathBuf, +} + +impl Drop for FileLockGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn acquire_file_lock(path: &Path, timeout_ms: u64) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create lock parent directory {}", + parent.display() + ) + })?; + } + + let started = std::time::Instant::now(); + loop { + match OpenOptions::new().create_new(true).write(true).open(path) { + Ok(mut file) => { + file.write_all(std::process::id().to_string().as_bytes())?; + file.sync_all()?; + return Ok(FileLockGuard { + path: path.to_path_buf(), + }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if started.elapsed() >= Duration::from_millis(timeout_ms) { + anyhow::bail!("lock busy: {}", path.display()); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to acquire lock {}", path.display())); + } + } + } +} + +fn resolve_install_method( + override_method: Option, + detected_method: Option, +) -> (InstallMethod, Option, &'static str) { + if let Some(method) = override_method { + return (method.clone(), Some(method), "override"); + } + if let Some(method) = detected_method { + return (method, None, "detected"); + } + (InstallMethod::Unknown, None, "unknown") +} + +#[derive(Debug, Clone)] +struct InstallDetectionContext { + current_exe: Option, + npm_user_agent: Option, + cargo_home: Option, + home_dir: Option, +} + +impl InstallDetectionContext { + fn from_runtime() -> Self { + Self { + current_exe: std::env::current_exe().ok(), + npm_user_agent: std::env::var("npm_config_user_agent").ok(), + cargo_home: std::env::var_os("CARGO_HOME").map(PathBuf::from), + home_dir: std::env::var_os("HOME").map(PathBuf::from), + } + } +} + +fn detect_install_method() -> Option { + if let Ok(test_override) = std::env::var("CORVUS_TEST_INSTALL_METHOD") { + return test_override.parse::().ok(); + } + + let context = InstallDetectionContext::from_runtime(); + detect_install_method_with_context(&context) +} + +fn detect_install_method_with_context(context: &InstallDetectionContext) -> Option { + if let Some(method) = detect_install_method_from_user_agent(context.npm_user_agent.as_deref()) { + return Some(method); + } + + let mut candidates = Vec::new(); + if let Some(exe) = context.current_exe.as_ref() { + candidates.push(exe.clone()); + if let Ok(target) = fs::read_link(exe) { + let resolved = if target.is_absolute() { + target + } else { + exe.parent() + .map_or(target.clone(), |parent| parent.join(target)) + }; + candidates.push(resolved); + } + } + + for candidate in &candidates { + if let Some(method) = detect_install_method_from_path(candidate, context) { + return Some(method); + } + } + + None +} + +fn detect_install_method_from_user_agent(user_agent: Option<&str>) -> Option { + let normalized = user_agent?.trim().to_ascii_lowercase(); + if normalized.starts_with("pnpm/") { + return Some(InstallMethod::Pnpm); + } + if normalized.starts_with("yarn/") { + return Some(InstallMethod::Yarn); + } + if normalized.starts_with("bun/") { + return Some(InstallMethod::Bun); + } + if normalized.starts_with("npm/") { + return Some(InstallMethod::Npm); + } + None +} + +fn detect_install_method_from_path( + executable_path: &Path, + context: &InstallDetectionContext, +) -> Option { + let normalized = executable_path + .to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase(); + + if normalized.contains("/cellar/") || normalized.contains("/homebrew/") { + return Some(InstallMethod::Homebrew); + } + + if is_cargo_install_path(executable_path, context) || normalized.contains("/.cargo/bin/") { + return Some(InstallMethod::Cargo); + } + + if normalized.contains("/.pnpm/") + || normalized.contains("/pnpm/global/") + || normalized.contains("/share/pnpm/") + { + return Some(InstallMethod::Pnpm); + } + + if normalized.contains("/.yarn/") || normalized.contains("/yarn/global/") { + return Some(InstallMethod::Yarn); + } + + if normalized.contains("/.bun/") || normalized.contains("/bun/") { + return Some(InstallMethod::Bun); + } + + if normalized.contains("/node_modules/.bin/") || normalized.contains("/lib/node_modules/") { + return Some(InstallMethod::Npm); + } + + let stem = executable_path + .file_stem() + .and_then(|v| v.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if stem == "corvus" + && (normalized.contains("/usr/local/bin/") + || normalized.contains("/usr/bin/") + || normalized.contains("/opt/bin/") + || normalized.contains("/opt/local/bin/") + || normalized.ends_with("/corvus")) + { + return Some(InstallMethod::ScriptBinary); + } + + None +} + +fn is_cargo_install_path(path: &Path, context: &InstallDetectionContext) -> bool { + if let Some(cargo_home) = context.cargo_home.as_ref() { + let bin = cargo_home.join("bin"); + if path.starts_with(&bin) { + return true; + } + } + + if let Some(home_dir) = context.home_dir.as_ref() { + let default_cargo_bin = home_dir.join(".cargo").join("bin"); + if path.starts_with(default_cargo_bin) { + return true; + } + } + + false +} + +fn save_state_snapshot_sync(workspace_dir: &Path, snapshot: &UpdateStateSnapshot) -> Result<()> { + let path = version_check_path(workspace_dir); + let body = + serde_json::to_vec_pretty(snapshot).context("failed to serialize update snapshot")?; + atomic_write_sync(&path, &body) +} + +fn atomic_write_sync(path: &Path, body: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + + let temp_path = path.with_extension(format!( + "tmp.{}.{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + + let mut temp_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .with_context(|| format!("failed to create temp file {}", temp_path.display()))?; + temp_file.write_all(body)?; + temp_file.sync_all()?; + drop(temp_file); + + fs::rename(&temp_path, path) + .with_context(|| format!("failed to atomically replace {}", path.display()))?; + sync_directory_sync( + path.parent() + .ok_or_else(|| anyhow::anyhow!("state path missing parent"))?, + )?; + Ok(()) +} + +fn sync_directory_sync(path: &Path) -> Result<()> { + #[cfg(unix)] + { + let directory = fs::File::open(path) + .with_context(|| format!("failed to open directory {}", path.display()))?; + directory.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +fn load_state_snapshot_sync(workspace_dir: &Path) -> Result> { + let path = version_check_path(workspace_dir); + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read update snapshot at {}", path.display()))?; + + if let Ok(snapshot) = serde_json::from_str::(&raw) { + return Ok(Some(snapshot)); + } + if let Ok(legacy) = serde_json::from_str::(&raw) { + return Ok(Some(legacy.into())); + } + anyhow::bail!("failed to parse update snapshot") +} + +fn append_audit_event_sync( + workspace_dir: &Path, + policy: &UpdatePolicy, + event: UpdateAuditEvent, +) -> Result<()> { + let history_path = update_history_path(workspace_dir); + if let Some(parent) = history_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut events = read_update_history_sync(workspace_dir).unwrap_or_default(); + events.push(event); + let max_entries = policy.history_max_entries.max(1) as usize; + if events.len() > max_entries { + let drain = events.len() - max_entries; + events.drain(0..drain); + } + let mut payload = Vec::new(); + for item in &events { + payload.extend(serde_json::to_vec(item)?); + payload.push(b'\n'); + } + atomic_write_sync(&history_path, &payload) +} + +fn read_update_history_sync(workspace_dir: &Path) -> Result> { + let history_path = update_history_path(workspace_dir); + if !history_path.exists() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&history_path)?; + let mut events = Vec::new(); + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(event) = serde_json::from_str::(trimmed) { + events.push(event); + } + } + Ok(events) +} + +fn verify_sha256_checksum(path: &Path, expected_hex: &str) -> Result<()> { + let expected = expected_hex.trim().to_ascii_lowercase(); + if expected.is_empty() { + anyhow::bail!("missing checksum metadata") + } + let bytes = + fs::read(path).with_context(|| format!("failed to read artifact {}", path.display()))?; + let actual = hex::encode(Sha256::digest(bytes)); + if actual != expected { + anyhow::bail!("digest mismatch") + } + Ok(()) +} + fn now_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1129,25 +2174,25 @@ async fn load_state(path: &Path) -> Result> { let raw = tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read version check state at {}", path.display()))?; - let state = serde_json::from_str::(&raw) + if let Ok(state) = serde_json::from_str::(&raw) { + return Ok(Some(state)); + } + let snapshot = serde_json::from_str::(&raw) .context("failed to parse version check state")?; - Ok(Some(state)) + Ok(Some(VersionCheckState { + latest_version: snapshot.latest_version, + checked_at_unix: snapshot.last_check_at_unix, + update_available: snapshot.update_available, + last_notified_version: None, + pending_confirmations: snapshot.pending_confirmations, + notified_conversations: snapshot.notified_conversations, + })) } +#[allow(clippy::unused_async)] async fn save_state(path: &Path, state: &VersionCheckState) -> Result<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await.with_context(|| { - format!( - "failed to create version check state directory {}", - parent.display() - ) - })?; - } - let body = serde_json::to_vec_pretty(state).context("failed to serialize version state")?; - tokio::fs::write(path, body) - .await - .with_context(|| format!("failed to write version check state at {}", path.display())) + atomic_write_sync(path, &body) } async fn fetch_latest_release_version() -> Result { @@ -1658,4 +2703,292 @@ mod tests { assert!(!sent.unwrap()); assert!(channel_impl.sent_messages.lock().await.is_empty()); } + + #[test] + fn install_method_resolution_prefers_override_then_detected_then_unknown() { + let (effective, overridden, source) = + resolve_install_method(Some(InstallMethod::Cargo), Some(InstallMethod::Npm)); + assert_eq!(effective, InstallMethod::Cargo); + assert_eq!(overridden, Some(InstallMethod::Cargo)); + assert_eq!(source, "override"); + + let (effective, overridden, source) = + resolve_install_method(None, Some(InstallMethod::Npm)); + assert_eq!(effective, InstallMethod::Npm); + assert_eq!(overridden, None); + assert_eq!(source, "detected"); + + let (effective, overridden, source) = resolve_install_method(None, None); + assert_eq!(effective, InstallMethod::Unknown); + assert_eq!(overridden, None); + assert_eq!(source, "unknown"); + } + + #[test] + fn install_method_detection_matrix_covers_supported_runtime_patterns() { + let context = InstallDetectionContext { + current_exe: None, + npm_user_agent: Some("pnpm/9.0.0 npm/? node/?".to_string()), + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Pnpm) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from( + "/opt/homebrew/Cellar/corvus/1.2.3/bin/corvus", + )), + npm_user_agent: None, + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Homebrew) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from("/Users/dev/.cargo/bin/corvus")), + npm_user_agent: None, + cargo_home: None, + home_dir: Some(PathBuf::from("/Users/dev")), + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Cargo) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from("/Users/dev/.bun/bin/corvus")), + npm_user_agent: None, + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Bun) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from( + "/Users/dev/.local/share/pnpm/global/5/node_modules/.bin/corvus", + )), + npm_user_agent: None, + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Pnpm) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from("/Users/dev/.yarn/bin/corvus")), + npm_user_agent: None, + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Yarn) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from( + "/usr/local/lib/node_modules/@dallay/corvus/bin/corvus.js", + )), + npm_user_agent: None, + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::Npm) + ); + + let context = InstallDetectionContext { + current_exe: Some(PathBuf::from("/usr/local/bin/corvus")), + npm_user_agent: None, + cargo_home: None, + home_dir: None, + }; + assert_eq!( + detect_install_method_with_context(&context), + Some(InstallMethod::ScriptBinary) + ); + } + + #[test] + fn consume_pending_confirmation_honors_scope_and_marks_nonce_used() { + let nonce = "nonce-abc"; + let mut state = VersionCheckState { + latest_version: "1.2.3".to_string(), + checked_at_unix: now_unix_secs(), + update_available: true, + last_notified_version: None, + pending_confirmations: vec![PendingConfirmation { + version: "1.2.3".to_string(), + channel: "telegram".to_string(), + recipient: "chat-1".to_string(), + authorized_sender: Some("sender-1".to_string()), + nonce_hash: hash_nonce(nonce), + expires_at_unix: now_unix_secs() + 60, + used: false, + }], + notified_conversations: Vec::new(), + }; + + let version = consume_pending_confirmation( + &mut state, + nonce, + Some(("telegram", "chat-1", "sender-1")), + ) + .unwrap(); + assert_eq!(version, "1.2.3"); + assert!(state.pending_confirmations[0].used); + } + + #[test] + fn update_manager_install_returns_busy_when_install_lock_held() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = dir.path().to_path_buf(); + + let lock_path = update_install_lock_path(&cfg.workspace_dir); + let _held = acquire_file_lock(&lock_path, 10).unwrap(); + + let manager = UpdateManager::new(&cfg); + let (outcome, message) = manager.install("1.0.0", "test").unwrap(); + assert_eq!(outcome, InstallCommandOutcome::Busy); + assert!(message.contains("busy")); + } + + #[test] + fn load_snapshot_ignores_partial_temp_file_and_keeps_valid_state() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + let snapshot = UpdateStateSnapshot::initial( + "1.0.0", + &UpdatePolicy { + checks_enabled: true, + auto_install_enabled: false, + channel_visibility_enabled: true, + cli_startup_notice_enabled: true, + check_interval_minutes: 30, + confirmation_ttl_minutes: 30, + install_method_override: None, + restart_policy: RestartPolicy::Prompt, + history_max_entries: 200, + }, + ); + save_state_snapshot_sync(workspace, &snapshot).unwrap(); + + let temp = version_check_path(workspace).with_extension("tmp.partial"); + if let Some(parent) = temp.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&temp, "{\"broken\":").unwrap(); + + let loaded = load_state_snapshot_sync(workspace).unwrap().unwrap(); + assert_eq!(loaded.current_version, snapshot.current_version); + } + + #[test] + fn verification_fails_closed_on_mismatch_and_audit_history_records_event() { + let dir = tempfile::tempdir().unwrap(); + let artifact_path = dir.path().join("artifact.bin"); + fs::write(&artifact_path, b"v1").unwrap(); + + let err = verify_sha256_checksum(&artifact_path, "deadbeef").unwrap_err(); + assert!(err.to_string().contains("digest mismatch")); + + let policy = UpdatePolicy { + checks_enabled: true, + auto_install_enabled: false, + channel_visibility_enabled: true, + cli_startup_notice_enabled: true, + check_interval_minutes: 30, + confirmation_ttl_minutes: 30, + install_method_override: None, + restart_policy: RestartPolicy::Prompt, + history_max_entries: 10, + }; + append_audit_event_sync( + dir.path(), + &policy, + UpdateAuditEvent { + event_id: "event-1".to_string(), + timestamp_unix: 1, + action: "verification".to_string(), + outcome: "failed".to_string(), + current_version: "1.0.0".to_string(), + target_version: Some("1.0.1".to_string()), + effective_method: "script_binary".to_string(), + actor: "test".to_string(), + reason_code: Some("digest mismatch".to_string()), + }, + ) + .unwrap(); + + let history = read_update_history_sync(dir.path()).unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, "verification"); + assert_eq!(history[0].outcome, "failed"); + } + + #[test] + fn verification_success_allows_activation_and_records_success_audit_events() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = dir.path().to_path_buf(); + cfg.updates.install_method_override = Some("script_binary".to_string()); + + let policy = UpdatePolicy::from_config(&cfg); + let mut snapshot = UpdateStateSnapshot::initial("1.0.0", &policy); + snapshot.latest_version = "1.0.1".to_string(); + snapshot.update_available = true; + save_state_snapshot_sync(&cfg.workspace_dir, &snapshot).unwrap(); + + let artifact_path = dir.path().join("artifact-ok.bin"); + fs::write(&artifact_path, b"verified-artifact").unwrap(); + let digest = Sha256::digest(b"verified-artifact"); + let expected_sha = hex::encode(digest); + + unsafe { + std::env::set_var("CORVUS_UPDATE_ARTIFACT_PATH", artifact_path.as_os_str()); + std::env::set_var("CORVUS_UPDATE_EXPECTED_SHA256", &expected_sha); + } + + let manager = UpdateManager::new(&cfg); + let (outcome, message) = manager + .install("1.0.0", "test-verification-success") + .unwrap(); + + unsafe { + std::env::remove_var("CORVUS_UPDATE_ARTIFACT_PATH"); + std::env::remove_var("CORVUS_UPDATE_EXPECTED_SHA256"); + } + + assert_eq!(outcome, InstallCommandOutcome::Success); + assert!(message.contains("update installed")); + + let loaded = load_state_snapshot_sync(&cfg.workspace_dir) + .unwrap() + .unwrap(); + assert!(matches!( + loaded.install_state, + InstallState::InstalledPendingRestart { .. } + )); + + let history = read_update_history_sync(&cfg.workspace_dir).unwrap(); + assert!(history + .iter() + .any(|event| event.action == "verification" && event.outcome == "success")); + assert!(history + .iter() + .any(|event| event.action == "install" && event.outcome == "success")); + } } diff --git a/clients/agent-runtime/tests/admin_config_api_integration.rs b/clients/agent-runtime/tests/admin_config_api_integration.rs index 9dfe18cbe..f922295e8 100644 --- a/clients/agent-runtime/tests/admin_config_api_integration.rs +++ b/clients/agent-runtime/tests/admin_config_api_integration.rs @@ -90,9 +90,11 @@ impl Memory for IntegrationMemory { fn temp_config() -> Config { let root = std::env::temp_dir().join(format!("corvus-admin-config-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp root"); - let mut config = Config::default(); - config.config_path = root.join("config.toml"); - config.workspace_dir = root.join("workspace"); + let config = Config { + config_path: root.join("config.toml"), + workspace_dir: root.join("workspace"), + ..Config::default() + }; std::fs::create_dir_all(&config.workspace_dir).expect("create workspace"); config } @@ -160,6 +162,19 @@ async fn get_admin_config_redacts_secrets() { body.pointer("/config/channels/webhook/has_secret"), Some(&serde_json::json!(true)) ); + assert_eq!( + body.pointer("/config/updates/auto_install_enabled"), + Some(&serde_json::json!(false)) + ); + assert!(body + .pointer("/config/updates/status/current_version") + .is_some()); + assert!(body + .pointer("/config/updates/status/last_check_outcome") + .is_some()); + assert!(body + .pointer("/config/updates/status/last_check_at_unix") + .is_some()); let text = body.to_string(); assert!(!text.contains("top-secret")); } diff --git a/clients/agent-runtime/tests/update_system_integration.rs b/clients/agent-runtime/tests/update_system_integration.rs new file mode 100644 index 000000000..66edd484b --- /dev/null +++ b/clients/agent-runtime/tests/update_system_integration.rs @@ -0,0 +1,206 @@ +use corvus::{config::Config, gateway::admin, update}; +use sha2::{Digest, Sha256}; +use std::process::Command; + +fn run_corvus(workspace: &std::path::Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_corvus")); + command + .args(args) + .env("CORVUS_WORKSPACE", workspace) + .env("CORVUS_DISABLE_UPDATE_CHECK", "1"); + command.output().expect("corvus command should execute") +} + +fn stdout_text(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr_text(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn make_workspace() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("temp workspace"); + std::fs::create_dir_all(dir.path().join("workspace").join("state")) + .expect("workspace state dir"); + dir +} + +fn hash_nonce(nonce: &str) -> String { + let digest = Sha256::digest(nonce.as_bytes()); + hex::encode(digest) +} + +#[test] +fn update_help_lists_full_command_contract() { + let workspace = make_workspace(); + let output = run_corvus(workspace.path(), &["update", "--help"]); + assert!(output.status.success()); + let stdout = stdout_text(&output); + assert!(stdout.contains("status")); + assert!(stdout.contains("check")); + assert!(stdout.contains("install")); + assert!(stdout.contains("auto-enable")); + assert!(stdout.contains("auto-disable")); + assert!(stdout.contains("history")); + assert!(stdout.contains("confirm")); +} + +#[test] +fn update_status_and_policy_toggles_are_visible_across_commands() { + let workspace = make_workspace(); + + let enable = run_corvus(workspace.path(), &["update", "auto-enable"]); + assert!(enable.status.success(), "{}", stderr_text(&enable)); + + let status_enabled = run_corvus(workspace.path(), &["update", "status"]); + assert!( + status_enabled.status.success(), + "{}", + stderr_text(&status_enabled) + ); + let stdout_enabled = stdout_text(&status_enabled); + assert!(stdout_enabled.contains("policy.auto_install_enabled=true")); + + let disable = run_corvus(workspace.path(), &["update", "auto-disable"]); + assert!(disable.status.success(), "{}", stderr_text(&disable)); + + let status_disabled = run_corvus(workspace.path(), &["update", "status"]); + assert!( + status_disabled.status.success(), + "{}", + stderr_text(&status_disabled) + ); + let stdout_disabled = stdout_text(&status_disabled); + assert!(stdout_disabled.contains("policy.auto_install_enabled=false")); +} + +#[test] +fn update_install_reports_busy_when_lock_is_held() { + let workspace = make_workspace(); + let lock_path = workspace + .path() + .join("workspace") + .join("state") + .join("update_install.lock"); + std::fs::write(&lock_path, b"lock-holder").expect("create install lock"); + + let output = run_corvus(workspace.path(), &["update", "install"]); + assert!(!output.status.success()); + let combined = format!("{}\n{}", stdout_text(&output), stderr_text(&output)); + assert!(combined.contains("busy")); +} + +#[test] +fn update_check_and_history_commands_are_script_stable() { + let workspace = make_workspace(); + + let check = run_corvus(workspace.path(), &["update", "check"]); + let check_stdout = stdout_text(&check); + assert!( + check_stdout.contains("current_version=") || check_stdout.contains("latest_version="), + "expected deterministic check output, got: {}", + check_stdout + ); + + let history = run_corvus(workspace.path(), &["update", "history"]); + assert!(history.status.success(), "{}", stderr_text(&history)); +} + +#[test] +fn update_confirm_reports_deterministic_failure_for_unknown_nonce() { + let workspace = make_workspace(); + let output = run_corvus(workspace.path(), &["update", "confirm", "missing-nonce"]); + assert!(!output.status.success()); + let combined = format!("{}\n{}", stdout_text(&output), stderr_text(&output)); + assert!(combined.contains("invalid")); + assert!(combined.contains("nonce")); +} + +#[test] +fn update_confirm_consumes_nonce_and_records_history_event() { + let workspace = make_workspace(); + let state_path = workspace + .path() + .join("workspace") + .join("state") + .join("version_check.json"); + let nonce = "nonce-confirm-1"; + let state = serde_json::json!({ + "latest_version": "9.9.9", + "checked_at_unix": 1, + "update_available": true, + "last_notified_version": "9.9.9", + "pending_confirmations": [ + { + "version": "9.9.9", + "channel": "telegram", + "recipient": "chat-1", + "authorized_sender": "sender-1", + "nonce_hash": hash_nonce(nonce), + "expires_at_unix": 4102444800u64, + "used": false + } + ], + "notified_conversations": [] + }); + std::fs::write( + &state_path, + serde_json::to_vec_pretty(&state).expect("serialize state"), + ) + .expect("write state file"); + + let _confirm = run_corvus(workspace.path(), &["update", "confirm", nonce]); + + let history = run_corvus(workspace.path(), &["update", "history"]); + assert!(history.status.success(), "{}", stderr_text(&history)); + let history_stdout = stdout_text(&history); + assert!(history_stdout.contains("confirm_install")); + + let confirm_reuse = run_corvus(workspace.path(), &["update", "confirm", nonce]); + assert!(!confirm_reuse.status.success()); + let combined = format!( + "{}\n{}", + stdout_text(&confirm_reuse), + stderr_text(&confirm_reuse) + ); + assert!(combined.contains("invalid")); +} + +#[test] +fn cli_and_admin_surfaces_share_update_status_facts() { + let workspace = make_workspace(); + let config = Config { + workspace_dir: workspace.path().to_path_buf(), + config_path: workspace.path().join("config.toml"), + ..Config::default() + }; + + let cli_view = update::get_update_status(&config, env!("CARGO_PKG_VERSION")).expect("status"); + let admin_view = admin::admin_config_view(&config); + + assert_eq!( + admin_view.updates.status.current_version, + cli_view.current_version + ); + assert_eq!( + admin_view.updates.status.latest_version, + cli_view.latest_version + ); + assert_eq!( + admin_view.updates.status.update_available, + cli_view.update_available + ); + assert_eq!( + admin_view.updates.status.last_check_outcome, + cli_view.last_check_outcome + ); + assert_eq!( + admin_view.updates.status.last_check_at_unix, + cli_view.last_check_at_unix + ); + assert_eq!( + admin_view.updates.status.effective_install_method, + cli_view.effective_install_method + ); +} diff --git a/clients/web/apps/dashboard/src/App.vue b/clients/web/apps/dashboard/src/App.vue index 898af32e1..047dd29f7 100644 --- a/clients/web/apps/dashboard/src/App.vue +++ b/clients/web/apps/dashboard/src/App.vue @@ -2,8 +2,8 @@ import { computed } from "vue"; import { useI18n } from "vue-i18n"; -import GeneralSettings from "@/components/config/GeneralSettings.vue"; import GatewaySettings from "@/components/config/GatewaySettings.vue"; +import GeneralSettings from "@/components/config/GeneralSettings.vue"; import ObservabilitySettings from "@/components/config/ObservabilitySettings.vue"; import RuntimeSettings from "@/components/config/RuntimeSettings.vue"; import SchedulerSettings from "@/components/config/SchedulerSettings.vue"; @@ -32,7 +32,7 @@ const { } = config; const webhookSecretStatusLabel = computed(() => - form.webhook_secret_exists ? t("webhook.statusConfigured") : t("webhook.statusNotConfigured"), + form.webhook_secret_exists ? t("webhook.statusConfigured") : t("webhook.statusNotConfigured") ); diff --git a/clients/web/apps/dashboard/src/components/config/GatewaySettings.spec.ts b/clients/web/apps/dashboard/src/components/config/GatewaySettings.spec.ts index 8fb13406b..c8b429d79 100644 --- a/clients/web/apps/dashboard/src/components/config/GatewaySettings.spec.ts +++ b/clients/web/apps/dashboard/src/components/config/GatewaySettings.spec.ts @@ -1,6 +1,6 @@ import { mount } from "@vue/test-utils"; -import { createI18n } from "vue-i18n"; import { describe, expect, it } from "vitest"; +import { createI18n } from "vue-i18n"; import GatewaySettings from "@/components/config/GatewaySettings.vue"; import { i18nConfig } from "@/i18n"; diff --git a/clients/web/apps/dashboard/src/components/config/GatewaySettings.vue b/clients/web/apps/dashboard/src/components/config/GatewaySettings.vue index 2e1fe1298..cac6f749f 100644 --- a/clients/web/apps/dashboard/src/components/config/GatewaySettings.vue +++ b/clients/web/apps/dashboard/src/components/config/GatewaySettings.vue @@ -1,8 +1,7 @@