fix(config): recover docker runtime path on save (#3165)

* fix(config): recover docker runtime path on save

* fix: update config_path in-memory after save() resolves bare filename

Change save() signature from &self to &mut self so it can assign the
resolved config_path back to the struct. This ensures downstream reads
(proxy_config, model_routing_config) use the correct absolute path
instead of a stale bare filename.

Add test assertion verifying config.config_path equals resolved path
after save(). Update all callers to use mutable bindings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-03-11 18:47:01 -04:00 committed by GitHub
parent b21223a6aa
commit 069b8e0586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 87 additions and 21 deletions

View File

@ -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<PathBuf> {
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() {

View File

@ -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, &current_config);
let mut new_config = hydrate_config_for_save(incoming, &current_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(&current, &id, &body.fields) {
let mut updated = match apply_integration_credentials_update(&current, &id, &body.fields) {
Ok(config) => config,
Err(error) if error.starts_with("Unknown integration id:") => {
return (

View File

@ -128,7 +128,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
// ── 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| {

View File

@ -921,7 +921,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()

View File

@ -450,7 +450,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()