fix: remove unsafe set_var, use thread-safe overlay for injected secrets

Address PR #92 review comments:
- Replace all 5 unsafe `std::env::set_var()` calls with safe alternatives
- Add INJECTED_VARS OnceLock<HashMap> overlay in config.rs, checked by
  optional_env() before falling back to std::env::var()
- Cache wizard API key in SetupWizard.llm_api_key field instead of env
- Pass explicit key param to fetch_anthropic_models/fetch_openai_models
- Persist env-provided API keys to secrets store during onboarding

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-14 14:14:22 -08:00
co-authored by Claude Opus 4.6
parent c7e6833d14
commit aa808ca94e
2 changed files with 73 additions and 50 deletions
+36 -30
View File
@@ -82,6 +82,8 @@ pub struct SetupWizard {
db_backend: Option<crate::db::libsql_backend::LibSqlBackend>,
/// Secrets crypto (created during setup).
secrets_crypto: Option<Arc<SecretsCrypto>>,
/// Cached API key from provider setup (used by model fetcher without env mutation).
llm_api_key: Option<String>,
}
impl SetupWizard {
@@ -96,6 +98,7 @@ impl SetupWizard {
#[cfg(feature = "libsql")]
db_backend: None,
secrets_crypto: None,
llm_api_key: None,
}
}
@@ -110,6 +113,7 @@ impl SetupWizard {
#[cfg(feature = "libsql")]
db_backend: None,
secrets_crypto: None,
llm_api_key: None,
}
}
@@ -734,6 +738,14 @@ impl SetupWizard {
if let Ok(existing) = std::env::var(env_var) {
print_info(&format!("{env_var} found: {}", mask_api_key(&existing)));
if confirm("Use this key?", true).map_err(SetupError::Io)? {
// Persist env-provided key to secrets store for future runs
if let Ok(ctx) = self.init_secrets_context().await {
let key = SecretString::from(existing.clone());
if let Err(e) = ctx.save_secret(secret_name, &key).await {
tracing::warn!("Failed to persist env key to secrets: {}", e);
}
}
self.llm_api_key = Some(existing);
print_success(&format!("{display_name} configured (from env)"));
return Ok(());
}
@@ -762,10 +774,8 @@ impl SetupWizard {
));
}
// SAFETY: Onboarding runs single-threaded before the async runtime spawns workers.
unsafe {
std::env::set_var(env_var, key_str);
}
// Cache key in memory for model fetching later in the wizard
self.llm_api_key = Some(key_str.to_string());
print_success(&format!("{display_name} configured"));
Ok(())
@@ -793,11 +803,6 @@ impl SetupWizard {
let url = url_input.unwrap_or_else(|| default_url.to_string());
self.settings.ollama_base_url = Some(url.clone());
// SAFETY: Onboarding runs single-threaded before the async runtime spawns workers.
unsafe {
std::env::set_var("OLLAMA_BASE_URL", &url);
}
print_success(&format!("Ollama configured ({})", url));
Ok(())
}
@@ -830,10 +835,6 @@ impl SetupWizard {
}
self.settings.openai_compatible_base_url = Some(url.clone());
// SAFETY: Onboarding runs single-threaded before the async runtime spawns workers.
unsafe {
std::env::set_var("LLM_BASE_URL", &url);
}
// Optional API key
if confirm("Does this endpoint require an API key?", false).map_err(SetupError::Io)? {
@@ -851,11 +852,6 @@ impl SetupWizard {
} else {
print_info("Secrets not available. Set LLM_API_KEY in your environment.");
}
// SAFETY: Onboarding runs single-threaded before the async runtime spawns workers.
unsafe {
std::env::set_var("LLM_API_KEY", key_str);
}
}
}
@@ -887,11 +883,11 @@ impl SetupWizard {
match backend {
"anthropic" => {
let models = fetch_anthropic_models().await;
let models = fetch_anthropic_models(self.llm_api_key.as_deref()).await;
self.select_from_model_list(&models)?;
}
"openai" => {
let models = fetch_openai_models().await;
let models = fetch_openai_models(self.llm_api_key.as_deref()).await;
self.select_from_model_list(&models)?;
}
"ollama" => {
@@ -1619,7 +1615,7 @@ fn mask_password_in_url(url: &str) -> String {
/// Fetch models from the Anthropic API.
///
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
async fn fetch_anthropic_models() -> Vec<(String, String)> {
async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> {
let static_defaults = vec![
("claude-sonnet-4-20250514".into(), "Claude Sonnet 4".into()),
("claude-opus-4-20250514".into(), "Claude Opus 4".into()),
@@ -1629,9 +1625,14 @@ async fn fetch_anthropic_models() -> Vec<(String, String)> {
),
];
let api_key = match std::env::var("ANTHROPIC_API_KEY") {
Ok(k) if !k.is_empty() => k,
_ => return static_defaults,
let api_key = cached_key
.map(String::from)
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.filter(|k| !k.is_empty());
let api_key = match api_key {
Some(k) => k,
None => return static_defaults,
};
let client = reqwest::Client::new();
@@ -1680,16 +1681,21 @@ async fn fetch_anthropic_models() -> Vec<(String, String)> {
/// Fetch models from the OpenAI API.
///
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
async fn fetch_openai_models() -> Vec<(String, String)> {
async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> {
let static_defaults = vec![
("gpt-4o".into(), "GPT-4o".into()),
("gpt-4o-mini".into(), "GPT-4o Mini (fast)".into()),
("o3".into(), "o3 (reasoning)".into()),
];
let api_key = match std::env::var("OPENAI_API_KEY") {
Ok(k) if !k.is_empty() => k,
_ => return static_defaults,
let api_key = cached_key
.map(String::from)
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.filter(|k| !k.is_empty());
let api_key = match api_key {
Some(k) => k,
None => return static_defaults,
};
let client = reqwest::Client::new();
@@ -2060,7 +2066,7 @@ mod tests {
async fn test_fetch_anthropic_models_static_fallback() {
// With no API key, should return static defaults
let _guard = EnvGuard::clear("ANTHROPIC_API_KEY");
let models = fetch_anthropic_models().await;
let models = fetch_anthropic_models(None).await;
assert!(!models.is_empty());
assert!(
models.iter().any(|(id, _)| id.contains("claude")),
@@ -2071,7 +2077,7 @@ mod tests {
#[tokio::test]
async fn test_fetch_openai_models_static_fallback() {
let _guard = EnvGuard::clear("OPENAI_API_KEY");
let models = fetch_openai_models().await;
let models = fetch_openai_models(None).await;
assert!(!models.is_empty());
assert!(
models.iter().any(|(id, _)| id.contains("gpt")),