diff --git a/.env.example b/.env.example index 7e4eb645d..2d29d2c2f 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,11 @@ OPENHUMAN_WORKSPACE= OPENHUMAN_TEMPERATURE=0.7 # [optional] Skill + agent tool execution timeout in seconds (default 120, max 3600) # OPENHUMAN_TOOL_TIMEOUT_SECS= +# [optional] Headless update restart contract: self_replace | supervisor +# OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=self_replace +# [optional] Allow bearer-authenticated RPC callers to invoke update.apply/update.run +# Disable on exposed server deployments unless you explicitly want remote self-upgrade. +# OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=true # --------------------------------------------------------------------------- # Runtime flags diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index b99c9cff5..18b4cd690 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4551,7 +4551,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.53.23" +version = "0.53.25" dependencies = [ "aes-gcm", "anyhow", diff --git a/docs/RELEASE-MANUAL-SMOKE.md b/docs/RELEASE-MANUAL-SMOKE.md index b00457330..0a1e05368 100644 --- a/docs/RELEASE-MANUAL-SMOKE.md +++ b/docs/RELEASE-MANUAL-SMOKE.md @@ -43,6 +43,7 @@ Applies to every release, all platforms. - [ ] **`.deb` and/or `.AppImage` install on a clean Ubuntu 22.04** — `sudo dpkg -i openhuman_*.deb` or `chmod +x openhuman-*.AppImage && ./openhuman-*.AppImage`. Expected: no missing-dependency errors; app launches. - [ ] **OS-native notification toasts fire** — Trigger a notification from inside the app (e.g. memory captured, agent finished). Expected: a libnotify-style toast appears outside the app window. (CI Linux sees only Xvfb; this surface verifies on a real desktop.) +- [ ] **Headless supervisor update stages without self-exit** — On a Linux service deployment with `[update] restart_strategy = "supervisor"` and `rpc_mutations_enabled = false`, stage a new core binary through the documented operator flow. Expected: the running process stays up until the supervisor restart, the staged binary is present on disk, and `systemctl restart openhuman` (or equivalent) picks up the new version. ### Cross-platform diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 6c316fcb2..e92e86e91 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -48,7 +48,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | ID | Feature | Layer | Test path(s) | Status | Notes | | ----- | ----------------------------- | ----- | -------------------------------------------------- | ------ | ------------------------------------- | -| 0.3.1 | Auto Update Check | RU+MS | `src/openhuman/update/` (Rust unit), release smoke | 🟡 | Core check covered; UI prompt manual | +| 0.3.1 | Auto Update Check | RU+RI+MS | `src/openhuman/update/` (Rust unit), `tests/json_rpc_e2e.rs`, release smoke | 🟡 | Core check/update policy covered; desktop prompt + release upgrade still manual | | 0.3.2 | Forced Update Handling | MS | release-manual-smoke | 🚫 | End-to-end gating verified at release | | 0.3.3 | Reinstall with Existing State | MS | release-manual-smoke | 🚫 | Workspace persistence on reinstall | | 0.3.4 | Clean Uninstall | MS | release-manual-smoke | 🚫 | OS removal paths | diff --git a/gitbooks/features/cloud-deploy.md b/gitbooks/features/cloud-deploy.md index 2e2335706..56b607ba0 100644 --- a/gitbooks/features/cloud-deploy.md +++ b/gitbooks/features/cloud-deploy.md @@ -214,6 +214,54 @@ Then run `openhuman-core serve` under your service manager of choice (systemd, supervisord, …) with the same environment variables documented above. +### Headless self-update contract + +Headless deployments should treat `openhuman.update_apply` as the safe primitive: +it downloads the release asset, writes it atomically next to the current binary, +and returns. Nothing exits automatically. + +`openhuman.update_run` follows `config.update.restart_strategy`: + +- `self_replace` (default): stage the binary, publish an in-process restart request, and let the running core respawn itself. +- `supervisor`: stage the binary and return `restart_requested=false`. Your outer service manager must restart the process. + +For long-running Linux services, set: + +```toml +[update] +restart_strategy = "supervisor" +rpc_mutations_enabled = false +``` + +or the equivalent env vars: + +```bash +OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor +OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false +``` + +Recommended `systemd` stance: + +```ini +Restart=always +ExecReload=/bin/kill -HUP $MAINPID +``` + +Operator flow: + +1. Call `openhuman.update_check` to discover a release. +2. Configure `restart_strategy = "supervisor"` in your `update.toml` (or set + `OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor`) so the core stages the + new binary without trying to re-exec itself, then call + `openhuman.update_apply` or `openhuman.update_run`. `restart_strategy` is a + configuration setting, not an RPC parameter. +3. Restart the unit explicitly: `systemctl restart openhuman`. + +If download or staging fails, the running binary is left in place and no +restart is requested. If a staged binary proves bad after restart, roll back by +restoring the previous binary from your package manager, image tag, or release +artifact and restarting the supervisor again. + The Compose file ([`docker-compose.yml`](../../docker-compose.yml)) maps the core on `:7788`, mounts a named volume `openhuman-workspace` for persistence, and sets `restart: unless-stopped` so the core comes back after host reboots. @@ -226,6 +274,10 @@ docker compose build docker compose up -d ``` +For RPC-exposed production deployments, prefer leaving mutating update RPCs +disabled (`OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false`) and perform +rollouts through your existing image tag or package-management flow instead. + ### Logs ```bash diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 93e605c76..c7bd72929 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -922,7 +922,7 @@ const CAPABILITIES: &[Capability] = &[ name: "Apply Core Update", domain: "update", category: CapabilityCategory::Settings, - description: "Download and stage a newer core binary, then restart the sidecar.", + description: "Download and stage a newer core binary. Desktop builds can self-restart; headless deployments can hand restart off to a supervisor.", how_to: "Settings > Developer Options > Apply Update", status: CapabilityStatus::Beta, privacy: None, diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 6c48bca5b..ef3716c49 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -34,9 +34,9 @@ pub use schema::{ ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, - StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig, VoiceActivationMode, - VoiceServerConfig, WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, - MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1, + StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig, UpdateRestartStrategy, + VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, + DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1, }; pub use schema::{ clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id, diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 023054ce4..4bd2e1411 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -5,7 +5,7 @@ use super::{ normalize_no_proxy_list, normalize_proxy_url_option, normalize_service_list, parse_proxy_enabled, parse_proxy_scope, set_runtime_proxy_config, ProxyScope, }, - Config, + Config, UpdateRestartStrategy, }; use anyhow::{Context, Result}; use directories::UserDirs; @@ -1061,6 +1061,30 @@ impl Config { self.update.interval_minutes = minutes; } } + if let Some(raw) = env.get("OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY") { + match raw.trim().to_ascii_lowercase().as_str() { + "self_replace" | "self-replace" | "self" => { + self.update.restart_strategy = UpdateRestartStrategy::SelfReplace; + } + "supervisor" | "stage_only" | "stage-only" => { + self.update.restart_strategy = UpdateRestartStrategy::Supervisor; + } + other => { + tracing::warn!( + value = other, + "ignoring invalid OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY \ + (valid: self_replace, supervisor)" + ); + } + } + } + if let Some(flag) = env.get("OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED") { + if let Some(enabled) = + parse_env_bool("OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED", &flag) + { + self.update.rpc_mutations_enabled = enabled; + } + } // Dictation overrides if let Some(flag) = env.get("OPENHUMAN_DICTATION_ENABLED") { diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index e862447a2..ead85a85e 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -810,6 +810,35 @@ fn env_overlay_auto_update_interval_parses_u32() { assert_eq!(cfg.update.interval_minutes, 60); } +#[test] +fn env_overlay_auto_update_restart_strategy_accepts_supported_values() { + let mut cfg = Config::default(); + cfg.apply_env_overlay_with( + &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY", "supervisor"), + ); + assert_eq!( + cfg.update.restart_strategy, + crate::openhuman::config::UpdateRestartStrategy::Supervisor + ); + + cfg.apply_env_overlay_with( + &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY", "self_replace"), + ); + assert_eq!( + cfg.update.restart_strategy, + crate::openhuman::config::UpdateRestartStrategy::SelfReplace + ); +} + +#[test] +fn env_overlay_auto_update_rpc_mutations_enabled_parses_bool() { + let mut cfg = Config::default(); + cfg.apply_env_overlay_with( + &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED", "false"), + ); + assert!(!cfg.update.rpc_mutations_enabled); +} + #[test] fn env_overlay_empty_lookup_leaves_defaults_intact() { // The seam with no env entries should be a no-op on a fresh Config. diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 82492a321..f89074b80 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -66,7 +66,7 @@ pub use tools::{ GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, MultimodalConfig, SecretsConfig, WebSearchConfig, }; -pub use update::UpdateConfig; +pub use update::{UpdateConfig, UpdateRestartStrategy}; mod voice_server; pub use voice_server::{VoiceActivationMode, VoiceServerConfig}; mod types; diff --git a/src/openhuman/config/schema/update.rs b/src/openhuman/config/schema/update.rs index d09f4badc..db8eff366 100644 --- a/src/openhuman/config/schema/update.rs +++ b/src/openhuman/config/schema/update.rs @@ -3,6 +3,22 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// How `update.run` should complete after staging a new binary. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UpdateRestartStrategy { + /// Request an in-process self-restart immediately after staging. + SelfReplace, + /// Stage the new binary and leave restart to an external supervisor. + Supervisor, +} + +impl Default for UpdateRestartStrategy { + fn default() -> Self { + Self::SelfReplace + } +} + /// Configuration for periodic self-update checks against GitHub Releases. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct UpdateConfig { @@ -14,6 +30,15 @@ pub struct UpdateConfig { /// Minimum enforced at runtime is 10 minutes. #[serde(default = "default_update_interval_minutes")] pub interval_minutes: u32, + + /// How `update.run` should handle restart after staging a new binary. + #[serde(default)] + pub restart_strategy: UpdateRestartStrategy, + + /// Whether bearer-authenticated RPC clients may invoke mutating update + /// methods (`update.apply`, `update.run`). + #[serde(default = "default_rpc_mutations_enabled")] + pub rpc_mutations_enabled: bool, } fn default_update_enabled() -> bool { @@ -24,11 +49,17 @@ fn default_update_interval_minutes() -> u32 { 60 } +fn default_rpc_mutations_enabled() -> bool { + true +} + impl Default for UpdateConfig { fn default() -> Self { Self { enabled: default_update_enabled(), interval_minutes: default_update_interval_minutes(), + restart_strategy: UpdateRestartStrategy::default(), + rpc_mutations_enabled: default_rpc_mutations_enabled(), } } } diff --git a/src/openhuman/update/core.rs b/src/openhuman/update/core.rs index f364e0815..054f8e509 100644 --- a/src/openhuman/update/core.rs +++ b/src/openhuman/update/core.rs @@ -4,6 +4,7 @@ use std::io::Write; use std::path::PathBuf; +use crate::openhuman::config::UpdateRestartStrategy; use crate::openhuman::update::types::{GitHubAsset, GitHubRelease, UpdateApplyResult, UpdateInfo}; /// GitHub owner/repo for the core binary releases. @@ -283,6 +284,7 @@ pub async fn download_and_stage_with_version( installed_version, staged_path: staged_path.to_string_lossy().to_string(), restart_required: true, + restart_strategy: UpdateRestartStrategy::SelfReplace, }) } diff --git a/src/openhuman/update/ops.rs b/src/openhuman/update/ops.rs index 882f87386..86d8823c0 100644 --- a/src/openhuman/update/ops.rs +++ b/src/openhuman/update/ops.rs @@ -4,10 +4,155 @@ use std::path::PathBuf; use serde_json::Value; +use crate::openhuman::config::{self, UpdateConfig, UpdateRestartStrategy}; use crate::openhuman::update; -use crate::openhuman::update::types::{UpdateRunResult, VersionInfo}; +use crate::openhuman::update::types::{ + UpdateApplyResult, UpdateInfo, UpdateRunResult, VersionInfo, +}; use crate::rpc::RpcOutcome; +async fn load_update_policy() -> Result { + config::rpc::load_config_with_timeout() + .await + .map(|cfg| cfg.update) + .map_err(|err| format!("failed to load config for update policy: {err}")) +} + +async fn enforce_update_mutation_policy(method: &str) -> Result { + let policy = load_update_policy().await.map_err(|err| { + let message = format!( + "{method} blocked: {err}; failing closed because update policy could not be loaded" + ); + log::error!("[update:rpc] {}", message); + message + })?; + if policy.rpc_mutations_enabled { + return Ok(policy); + } + + let message = format!( + "{method} is disabled by config.update.rpc_mutations_enabled=false; \ + use update.check for discovery and restart under a supervisor after staging manually" + ); + log::warn!("[update:rpc] {}", message); + Err(message) +} + +fn already_current_result( + info: &UpdateInfo, + restart_strategy: UpdateRestartStrategy, +) -> UpdateRunResult { + UpdateRunResult { + current_version: info.current_version.clone(), + latest_version: info.latest_version.clone(), + update_available: false, + applied: false, + staged_path: None, + restart_requested: false, + restart_strategy, + message: format!("already on latest ({})", info.current_version), + } +} + +fn missing_asset_result( + info: UpdateInfo, + restart_strategy: UpdateRestartStrategy, +) -> UpdateRunResult { + UpdateRunResult { + current_version: info.current_version, + latest_version: info.latest_version, + update_available: true, + applied: false, + staged_path: None, + restart_requested: false, + restart_strategy, + message: format!( + "latest release has no asset for target {}", + update::platform_triple() + ), + } +} + +fn apply_failure_result( + info: UpdateInfo, + restart_strategy: UpdateRestartStrategy, + error: &str, +) -> UpdateRunResult { + UpdateRunResult { + current_version: info.current_version, + latest_version: info.latest_version, + update_available: true, + applied: false, + staged_path: None, + restart_requested: false, + restart_strategy, + message: format!("download/stage failed: {error}"), + } +} + +async fn build_run_result_from_staged_update( + info: UpdateInfo, + mut applied: UpdateApplyResult, + restart_strategy: UpdateRestartStrategy, +) -> UpdateRunResult { + applied.restart_strategy = restart_strategy; + + match restart_strategy { + UpdateRestartStrategy::SelfReplace => { + let restart_requested = match crate::openhuman::service::rpc::service_restart( + Some("update.run".to_string()), + Some(format!("update to {}", info.latest_version)), + ) + .await + { + Ok(_) => true, + Err(e) => { + log::warn!( + "[update:rpc] update_run staged update but restart publish failed: {}", + e + ); + false + } + }; + + UpdateRunResult { + current_version: info.current_version, + latest_version: info.latest_version, + update_available: true, + applied: true, + staged_path: Some(applied.staged_path.clone()), + restart_requested, + restart_strategy, + message: if restart_requested { + format!( + "staged {} — restart requested", + applied.staged_path.as_str() + ) + } else { + format!( + "staged {} — restart publish failed; caller must restart manually", + applied.staged_path.as_str() + ) + }, + } + } + UpdateRestartStrategy::Supervisor => UpdateRunResult { + current_version: info.current_version, + latest_version: info.latest_version.clone(), + update_available: true, + applied: true, + staged_path: Some(applied.staged_path.clone()), + restart_requested: false, + restart_strategy, + message: format!( + "staged {} — supervisor restart required before {} takes effect", + applied.staged_path.as_str(), + info.latest_version + ), + }, + } +} + /// Report the running core binary's version + target triple. /// /// Cheap, no-network — the frontend uses this to decide whether to @@ -36,6 +181,20 @@ pub async fn update_version() -> RpcOutcome { /// core process will exit shortly afterwards. pub async fn update_run() -> RpcOutcome { log::info!("[update:rpc] update_run invoked"); + let policy = match enforce_update_mutation_policy("openhuman.update_run").await { + Ok(policy) => policy, + Err(error) => { + return RpcOutcome::single_log( + serde_json::json!({ + "error": error, + "applied": false, + "restart_requested": false, + }), + "update_run rejected by policy", + ); + } + }; + let restart_strategy = policy.restart_strategy; let info = match update::check_available().await { Ok(i) => i, @@ -53,18 +212,10 @@ pub async fn update_run() -> RpcOutcome { }; if !info.update_available { - let result = UpdateRunResult { - current_version: info.current_version.clone(), - latest_version: info.latest_version.clone(), - update_available: false, - applied: false, - staged_path: None, - restart_requested: false, - message: format!("already on latest ({})", info.current_version), - }; + let result = already_current_result(&info, restart_strategy); log::info!( "[update:rpc] update_run: already up to date ({})", - info.current_version + result.current_version ); return RpcOutcome::single_log( serde_json::to_value(&result).unwrap_or(Value::Null), @@ -72,23 +223,14 @@ pub async fn update_run() -> RpcOutcome { ); } - let (Some(download_url), Some(asset_name)) = (info.download_url, info.asset_name) else { + let (Some(download_url), Some(asset_name)) = + (info.download_url.clone(), info.asset_name.clone()) + else { log::warn!( "[update:rpc] update_run: latest release has no asset for this platform (target={})", update::platform_triple() ); - let result = UpdateRunResult { - current_version: info.current_version, - latest_version: info.latest_version, - update_available: true, - applied: false, - staged_path: None, - restart_requested: false, - message: format!( - "latest release has no asset for target {}", - update::platform_triple() - ), - }; + let result = missing_asset_result(info, restart_strategy); return RpcOutcome::single_log( serde_json::to_value(&result).unwrap_or(Value::Null), "update_run: missing platform asset", @@ -117,15 +259,7 @@ pub async fn update_run() -> RpcOutcome { Ok(r) => r, Err(e) => { log::error!("[update:rpc] update_run apply failed: {e}"); - let result = UpdateRunResult { - current_version: info.current_version, - latest_version: info.latest_version, - update_available: true, - applied: false, - staged_path: None, - restart_requested: false, - message: format!("download/stage failed: {e}"), - }; + let result = apply_failure_result(info, restart_strategy, &e); return RpcOutcome::single_log( serde_json::to_value(&result).unwrap_or(Value::Null), format!("update_run: apply failed: {e}"), @@ -133,43 +267,11 @@ pub async fn update_run() -> RpcOutcome { } }; - // Stage succeeded — request a self-restart so the Tauri shell can - // pick up the freshly-staged binary on its next supervised launch. - let restart_requested = match crate::openhuman::service::rpc::service_restart( - Some("update.run".to_string()), - Some(format!("update to {}", info.latest_version)), - ) - .await - { - Ok(_) => true, - Err(e) => { - log::warn!("[update:rpc] update_run staged update but restart publish failed: {e}"); - false - } - }; - - let result = UpdateRunResult { - current_version: info.current_version, - latest_version: info.latest_version, - update_available: true, - applied: true, - staged_path: Some(applied.staged_path.clone()), - restart_requested, - message: if restart_requested { - format!( - "staged {} — restart requested", - applied.staged_path.as_str() - ) - } else { - format!( - "staged {} — restart publish failed; caller must restart manually", - applied.staged_path.as_str() - ) - }, - }; + let result = build_run_result_from_staged_update(info, applied, restart_strategy).await; log::info!( - "[update:rpc] update_run completed applied=true restart_requested={}", - restart_requested + "[update:rpc] update_run completed applied=true restart_requested={} restart_strategy={:?}", + result.restart_requested, + result.restart_strategy ); RpcOutcome::single_log( serde_json::to_value(&result).unwrap_or(Value::Null), @@ -251,6 +353,15 @@ pub async fn update_apply( download_url, asset_name, ); + let policy = match enforce_update_mutation_policy("openhuman.update_apply").await { + Ok(policy) => policy, + Err(error) => { + return RpcOutcome::single_log( + serde_json::json!({ "error": error }), + "update_apply rejected by policy", + ); + } + }; // Validate inputs at the RPC boundary. if let Err(e) = validate_download_url(&download_url) { @@ -271,7 +382,8 @@ pub async fn update_apply( // Ignore caller-provided staging_dir — always use the safe default. let dir: Option = None; match update::download_and_stage(&download_url, &asset_name, dir).await { - Ok(result) => { + Ok(mut result) => { + result.restart_strategy = policy.restart_strategy; let value = serde_json::to_value(&result).unwrap_or_else( |e| serde_json::json!({ "error": format!("serialization failed: {e}") }), ); @@ -290,6 +402,18 @@ pub async fn update_apply( #[cfg(test)] mod tests { use super::*; + use crate::openhuman::config::TEST_ENV_LOCK; + use tempfile::TempDir; + + async fn write_update_policy(tmp: &TempDir, update: UpdateConfig) { + let mut cfg = crate::openhuman::config::Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..crate::openhuman::config::Config::default() + }; + cfg.update = update; + cfg.save().await.expect("save config"); + } // ── validate_download_url ───────────────────────────────────── @@ -394,6 +518,76 @@ mod tests { .any(|l| l.contains("update_apply rejected"))); } + struct WorkspaceEnvGuard; + impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + std::env::set_var("OPENHUMAN_WORKSPACE", path); + Self + } + } + impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } + } + + #[tokio::test] + async fn update_apply_rejects_when_rpc_mutations_disabled() { + let _guard = TEST_ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + let _workspace_guard = WorkspaceEnvGuard::set(tmp.path()); + write_update_policy( + &tmp, + UpdateConfig { + rpc_mutations_enabled: false, + ..UpdateConfig::default() + }, + ) + .await; + + let outcome = update_apply( + "https://github.com/owner/repo/releases/download/v1/x".to_string(), + "openhuman-core-x86_64.tar.gz".to_string(), + None, + ) + .await; + + assert!(outcome.value.get("error").is_some()); + assert!(outcome.value["error"] + .as_str() + .is_some_and(|value| value.contains("rpc_mutations_enabled=false"))); + } + + #[tokio::test] + async fn supervisor_restart_strategy_stages_without_restart_request() { + let info = UpdateInfo { + latest_version: "9.9.9".into(), + current_version: "1.0.0".into(), + update_available: true, + download_url: Some( + "https://github.com/owner/repo/releases/download/v9/openhuman-core".into(), + ), + asset_name: Some("openhuman-core-x86_64-unknown-linux-gnu".into()), + release_notes: None, + published_at: None, + }; + let applied = UpdateApplyResult { + installed_version: "9.9.9".into(), + staged_path: "/tmp/openhuman-core".into(), + restart_required: true, + restart_strategy: UpdateRestartStrategy::SelfReplace, + }; + + let result = + build_run_result_from_staged_update(info, applied, UpdateRestartStrategy::Supervisor) + .await; + + assert!(result.applied); + assert!(!result.restart_requested); + assert_eq!(result.restart_strategy, UpdateRestartStrategy::Supervisor); + assert!(result.message.contains("supervisor restart required")); + } + // NOTE: `update_check` and the success path of `update_apply` // hit GitHub's REST API and stage real binaries on disk — they // are deferred to the integration test suite (tests/) where a diff --git a/src/openhuman/update/scheduler.rs b/src/openhuman/update/scheduler.rs index 88a8b5c27..6341b59ae 100644 --- a/src/openhuman/update/scheduler.rs +++ b/src/openhuman/update/scheduler.rs @@ -103,6 +103,7 @@ mod tests { let cfg = UpdateConfig { enabled: false, interval_minutes: 0, + ..UpdateConfig::default() }; run(cfg).await; } diff --git a/src/openhuman/update/types.rs b/src/openhuman/update/types.rs index f4385a3f7..be75cbcde 100644 --- a/src/openhuman/update/types.rs +++ b/src/openhuman/update/types.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use crate::openhuman::config::UpdateRestartStrategy; + /// Summary of an available update from GitHub Releases. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateInfo { @@ -52,6 +54,8 @@ pub struct UpdateRunResult { /// True when a self-restart was published. The process will exit /// shortly after the RPC response is returned. pub restart_requested: bool, + /// The configured post-stage restart contract. + pub restart_strategy: UpdateRestartStrategy, /// Human-readable summary suitable for logs / surface text. pub message: String, } @@ -65,6 +69,8 @@ pub struct UpdateApplyResult { pub staged_path: String, /// Whether a restart is required to complete the update. pub restart_required: bool, + /// The configured post-stage restart contract. + pub restart_strategy: UpdateRestartStrategy, } /// Subset of the GitHub Releases API response we care about. diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 7bae66815..a2fb121d4 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -3548,6 +3548,61 @@ async fn external_process_with_guessed_token_is_rejected() { rpc_join.abort(); } +#[tokio::test] +async fn rpc_update_apply_can_be_disabled_by_config_policy() { + let _env_lock = json_rpc_e2e_env_lock(); + ensure_test_rpc_auth(); + + let tmp = tempdir().expect("tempdir"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); + + let mut config = openhuman_core::openhuman::config::Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..openhuman_core::openhuman::config::Config::default() + }; + config.update.rpc_mutations_enabled = false; + config + .save() + .await + .expect("persist config with update rpc disabled"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("http://{rpc_addr}/rpc")) + .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) + .header("Content-Type", "application/json") + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "openhuman.update_apply", + "params": { + "download_url": "https://github.com/owner/repo/releases/download/v1/x", + "asset_name": "openhuman-core-x86_64-unknown-linux-gnu" + } + })) + .send() + .await + .expect("request"); + + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.expect("json body"); + let error_msg = body + .get("result") + .and_then(|outer| outer.get("result")) + .and_then(|inner| inner.get("error")) + .and_then(Value::as_str) + .expect("policy error result message"); + assert!( + error_msg.contains("rpc_mutations_enabled=false"), + "unexpected error: {body}" + ); + + rpc_join.abort(); +} + /// End-to-end coverage for issue #1149: storing a managed-DM channel /// credential under `channel::` and immediately observing /// `connected:true` from `openhuman.channels_status`.