diff --git a/src/config/schema.rs b/src/config/schema.rs index dee0e8f28..edd8f6aa8 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -6691,11 +6691,41 @@ impl Config { set_runtime_proxy_config(self.proxy.clone()); } - pub async fn save(&self) -> Result<()> { - // Encrypt secrets before serialization - let mut config_to_save = self.clone(); - let zeroclaw_dir = self + async fn resolve_config_path_for_save(&self) -> Result { + if self .config_path + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()) + { + return Ok(self.config_path.clone()); + } + + let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?; + let (zeroclaw_dir, _workspace_dir, source) = + resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?; + let file_name = self + .config_path + .file_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| std::ffi::OsStr::new("config.toml")); + let resolved = zeroclaw_dir.join(file_name); + tracing::warn!( + path = %self.config_path.display(), + resolved = %resolved.display(), + source = source.as_str(), + "Config path missing parent directory; resolving from runtime environment" + ); + Ok(resolved) + } + + pub async fn save(&mut self) -> Result<()> { + // Encrypt secrets before serialization + let config_path = self.resolve_config_path_for_save().await?; + // Keep the in-memory config_path in sync so downstream reads + // (e.g. proxy_config, model_routing_config) use the resolved path. + self.config_path = config_path.clone(); + let mut config_to_save = self.clone(); + let zeroclaw_dir = config_path .parent() .context("Config path must have a parent directory")?; let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt); @@ -6764,8 +6794,7 @@ impl Config { let toml_str = toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?; - let parent_dir = self - .config_path + let parent_dir = config_path .parent() .context("Config path must have a parent directory")?; @@ -6776,8 +6805,7 @@ impl Config { ) })?; - let file_name = self - .config_path + let file_name = config_path .file_name() .and_then(|v| v.to_str()) .unwrap_or("config.toml"); @@ -6817,9 +6845,9 @@ impl Config { .context("Failed to fsync temporary config file")?; drop(temp_file); - let had_existing_config = self.config_path.exists(); + let had_existing_config = config_path.exists(); if had_existing_config { - fs::copy(&self.config_path, &backup_path) + fs::copy(&config_path, &backup_path) .await .with_context(|| { format!( @@ -6829,10 +6857,10 @@ impl Config { })?; } - if let Err(e) = fs::rename(&temp_path, &self.config_path).await { + if let Err(e) = fs::rename(&temp_path, &config_path).await { let _ = fs::remove_file(&temp_path).await; if had_existing_config && backup_path.exists() { - fs::copy(&backup_path, &self.config_path) + fs::copy(&backup_path, &config_path) .await .context("Failed to restore config backup")?; } @@ -6842,12 +6870,12 @@ impl Config { #[cfg(unix)] { use std::{fs::Permissions, os::unix::fs::PermissionsExt}; - fs::set_permissions(&self.config_path, Permissions::from_mode(0o600)) + fs::set_permissions(&config_path, Permissions::from_mode(0o600)) .await .with_context(|| { format!( "Failed to enforce secure permissions on config file: {}", - self.config_path.display() + config_path.display() ) })?; } @@ -7660,7 +7688,7 @@ tool_dispatcher = "xml" fs::create_dir_all(&dir).await.unwrap(); let config_path = dir.join("config.toml"); - let config = Config { + let mut config = Config { workspace_dir: dir.join("workspace"), config_path: config_path.clone(), api_key: Some("sk-roundtrip".into()), @@ -10486,6 +10514,44 @@ default_model = "legacy-model" ); } + #[test] + async fn save_repairs_bare_config_filename_using_runtime_resolution() { + let _env_guard = env_override_lock().await; + let temp_home = + std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4())); + let workspace_dir = temp_home.join("workspace"); + let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml"); + + let original_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", &temp_home); + std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir); + + let mut config = Config::default(); + config.workspace_dir = workspace_dir; + config.config_path = PathBuf::from("config.toml"); + config.default_temperature = 0.5; + config.save().await.unwrap(); + + assert!(resolved_config_path.exists()); + assert_eq!( + config.config_path, resolved_config_path, + "save() must update config_path to the resolved path" + ); + let saved = tokio::fs::read_to_string(&resolved_config_path) + .await + .unwrap(); + let parsed: Config = toml::from_str(&saved).unwrap(); + assert_eq!(parsed.default_temperature, 0.5); + + std::env::remove_var("ZEROCLAW_WORKSPACE"); + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = tokio::fs::remove_dir_all(temp_home).await; + } + #[cfg(unix)] #[test] async fn save_restricts_existing_world_readable_config_to_owner_only() { diff --git a/src/gateway/api.rs b/src/gateway/api.rs index 13da1c5e2..a13a59ae9 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -543,7 +543,7 @@ pub async fn handle_api_config_put( }; let current_config = state.config.lock().clone(); - let new_config = hydrate_config_for_save(incoming, ¤t_config); + let mut new_config = hydrate_config_for_save(incoming, ¤t_config); if let Err(e) = new_config.validate() { return ( @@ -752,7 +752,7 @@ pub async fn handle_api_integration_credentials_put( } } - let updated = match apply_integration_credentials_update(¤t, &id, &body.fields) { + let mut updated = match apply_integration_credentials_update(¤t, &id, &body.fields) { Ok(config) => config, Err(error) if error.starts_with("Unknown integration id:") => { return ( diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 159cd3aec..eccfde9d2 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -128,7 +128,7 @@ pub async fn run_wizard(force: bool) -> Result { // ── Build config ── // Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime - let config = Config { + let mut config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), api_key: if api_key.is_empty() { @@ -487,7 +487,7 @@ async fn run_quick_setup_with_home( // Create memory config based on backend choice let memory_config = memory_config_defaults_for_backend(&memory_backend_name); - let config = Config { + let mut config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), api_key: credential_override.map(|c| { diff --git a/src/tools/model_routing_config.rs b/src/tools/model_routing_config.rs index 1eaf7bb94..3adec24cc 100644 --- a/src/tools/model_routing_config.rs +++ b/src/tools/model_routing_config.rs @@ -921,7 +921,7 @@ mod tests { } async fn test_config(tmp: &TempDir) -> Arc { - let config = Config { + let mut config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default() diff --git a/src/tools/proxy_config.rs b/src/tools/proxy_config.rs index 213a57e0c..f58beed20 100644 --- a/src/tools/proxy_config.rs +++ b/src/tools/proxy_config.rs @@ -450,7 +450,7 @@ mod tests { } async fn test_config(tmp: &TempDir) -> Arc { - let config = Config { + let mut config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default()