mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Support reliable self-update for server-side and headless core instances (#1458)
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -4551,7 +4551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openhuman"
|
||||
version = "0.53.23"
|
||||
version = "0.53.25"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+264
-70
@@ -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<UpdateConfig, String> {
|
||||
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<UpdateConfig, String> {
|
||||
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<Value> {
|
||||
/// core process will exit shortly afterwards.
|
||||
pub async fn update_run() -> RpcOutcome<Value> {
|
||||
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<Value> {
|
||||
};
|
||||
|
||||
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<Value> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
};
|
||||
|
||||
// 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<PathBuf> = 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
|
||||
|
||||
@@ -103,6 +103,7 @@ mod tests {
|
||||
let cfg = UpdateConfig {
|
||||
enabled: false,
|
||||
interval_minutes: 0,
|
||||
..UpdateConfig::default()
|
||||
};
|
||||
run(cfg).await;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<slug>:<mode>` and immediately observing
|
||||
/// `connected:true` from `openhuman.channels_status`.
|
||||
|
||||
Reference in New Issue
Block a user