fix(config): add startup validation to catch invalid config values early

Add Config::validate() called from load_or_init() after env overrides
are applied. This catches obviously invalid configuration values at
startup instead of allowing them to silently cause runtime failures.

Validated fields:
- gateway.host: must not be empty
- autonomy.max_actions_per_hour: must be > 0
- scheduler.max_concurrent: must be > 0
- scheduler.max_tasks: must be > 0
- model_routes[*]: hint, provider, model must not be empty
- embedding_routes[*]: hint, provider, model must not be empty
- proxy: delegates to existing ProxyConfig::validate()

Previously, ProxyConfig::validate() was only called during
apply_env_overrides() and only warned/disabled on failure. The new
Config::validate() runs it as a hard error after all overrides are
resolved, ensuring proxy misconfiguration is surfaced early.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Gorevski 2026-02-19 11:37:30 -08:00
parent 77609777ab
commit 99cf2fdfee

View file

@ -2910,6 +2910,7 @@ impl Config {
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?; decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
} }
config.apply_env_overrides(); config.apply_env_overrides();
config.validate()?;
tracing::info!( tracing::info!(
path = %config.config_path.display(), path = %config.config_path.display(),
workspace = %config.workspace_dir.display(), workspace = %config.workspace_dir.display(),
@ -2932,6 +2933,7 @@ impl Config {
} }
config.apply_env_overrides(); config.apply_env_overrides();
config.validate()?;
tracing::info!( tracing::info!(
path = %config.config_path.display(), path = %config.config_path.display(),
workspace = %config.workspace_dir.display(), workspace = %config.workspace_dir.display(),
@ -2943,6 +2945,61 @@ impl Config {
} }
} }
/// Validate configuration values that would cause runtime failures.
///
/// Called after TOML deserialization and env-override application to catch
/// obviously invalid values early instead of failing at arbitrary runtime points.
pub fn validate(&self) -> Result<()> {
// Gateway
if self.gateway.host.trim().is_empty() {
anyhow::bail!("gateway.host must not be empty");
}
// Autonomy
if self.autonomy.max_actions_per_hour == 0 {
anyhow::bail!("autonomy.max_actions_per_hour must be greater than 0");
}
// Scheduler
if self.scheduler.max_concurrent == 0 {
anyhow::bail!("scheduler.max_concurrent must be greater than 0");
}
if self.scheduler.max_tasks == 0 {
anyhow::bail!("scheduler.max_tasks must be greater than 0");
}
// Model routes
for (i, route) in self.model_routes.iter().enumerate() {
if route.hint.trim().is_empty() {
anyhow::bail!("model_routes[{i}].hint must not be empty");
}
if route.provider.trim().is_empty() {
anyhow::bail!("model_routes[{i}].provider must not be empty");
}
if route.model.trim().is_empty() {
anyhow::bail!("model_routes[{i}].model must not be empty");
}
}
// Embedding routes
for (i, route) in self.embedding_routes.iter().enumerate() {
if route.hint.trim().is_empty() {
anyhow::bail!("embedding_routes[{i}].hint must not be empty");
}
if route.provider.trim().is_empty() {
anyhow::bail!("embedding_routes[{i}].provider must not be empty");
}
if route.model.trim().is_empty() {
anyhow::bail!("embedding_routes[{i}].model must not be empty");
}
}
// Proxy (delegate to existing validation)
self.proxy.validate()?;
Ok(())
}
/// Apply environment variable overrides to config /// Apply environment variable overrides to config
pub fn apply_env_overrides(&mut self) { pub fn apply_env_overrides(&mut self) {
// API Key: ZEROCLAW_API_KEY or API_KEY (generic) // API Key: ZEROCLAW_API_KEY or API_KEY (generic)