diff --git a/Cargo.lock b/Cargo.lock index 9a945ed7..d1137652 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3792,7 +3792,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.4.2" +version = "0.4.4" dependencies = [ "async-trait", "axum", @@ -3832,7 +3832,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.4.2" +version = "0.4.4" dependencies = [ "async-trait", "axum", @@ -3864,7 +3864,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.4.2" +version = "0.4.4" dependencies = [ "clap", "clap_complete", @@ -3891,7 +3891,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.4.2" +version = "0.4.4" dependencies = [ "axum", "open", @@ -3917,7 +3917,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.4.2" +version = "0.4.4" dependencies = [ "aes-gcm", "argon2", @@ -3945,7 +3945,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.4.2" +version = "0.4.4" dependencies = [ "chrono", "dashmap", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.4.2" +version = "0.4.4" dependencies = [ "async-trait", "chrono", @@ -3995,11 +3995,12 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zeroize", ] [[package]] name = "openfang-memory" -version = "0.4.2" +version = "0.4.4" dependencies = [ "async-trait", "chrono", @@ -4018,7 +4019,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.4.2" +version = "0.4.4" dependencies = [ "chrono", "dirs 6.0.0", @@ -4037,7 +4038,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.4.2" +version = "0.4.4" dependencies = [ "anyhow", "async-trait", @@ -4071,7 +4072,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.4.2" +version = "0.4.4" dependencies = [ "chrono", "hex", @@ -4094,7 +4095,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.4.2" +version = "0.4.4" dependencies = [ "async-trait", "chrono", @@ -4113,7 +4114,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.4.2" +version = "0.4.4" dependencies = [ "async-trait", "chrono", @@ -8776,7 +8777,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.4.2" +version = "0.4.4" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 35978f16..fb67a8eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.4.3" +version = "0.4.4" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 5b873175..292693b3 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -6953,7 +6953,10 @@ pub async fn set_provider_key( }) }; - // Write to secrets.env file + // Store in vault (best-effort — no-op if vault not initialized) + state.kernel.store_credential(&env_var, &key); + + // Write to secrets.env file (dual-write for backward compat / vault corruption recovery) let secrets_path = state.kernel.config.home_dir.join("secrets.env"); if let Err(e) = write_secret_env(&secrets_path, &env_var, &key) { return ( @@ -7115,6 +7118,9 @@ pub async fn delete_provider_key( ); } + // Remove from vault (best-effort) + state.kernel.remove_credential(&env_var); + // Remove from secrets.env let secrets_path = state.kernel.config.home_dir.join("secrets.env"); if let Err(e) = remove_secret_env(&secrets_path, &env_var) { @@ -10267,7 +10273,10 @@ pub async fn copilot_oauth_poll( Json(serde_json::json!({"status": "pending"})), ), openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => { - // Save to secrets.env + // Store in vault (best-effort) + state.kernel.store_credential("GITHUB_TOKEN", &access_token); + + // Save to secrets.env (dual-write) let secrets_path = state.kernel.config.home_dir.join("secrets.env"); if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) { return ( diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 9f1cbcc7..ab71228c 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -4813,6 +4813,10 @@ fn cmd_config_set_key(provider: &str) { return; } + // Try vault first (best-effort) + save_credential_prefer_vault(&env_var, &key); + + // Always save to dotenv as fallback match dotenv::save_env_key(&env_var, &key) { Ok(()) => { ui::success(&format!("Saved {env_var} to ~/.openfang/.env")); @@ -4835,6 +4839,18 @@ fn cmd_config_set_key(provider: &str) { fn cmd_config_delete_key(provider: &str) { let env_var = provider_to_env_var(provider); + // Remove from vault (best-effort) + { + let home = openfang_home(); + let vault_path = home.join("vault.enc"); + if vault_path.exists() { + let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path); + if vault.unlock().is_ok() { + let _ = vault.remove(&env_var); + } + } + } + match dotenv::remove_env_key(&env_var) { Ok(()) => ui::success(&format!("Removed {env_var} from ~/.openfang/.env")), Err(e) => { @@ -4864,6 +4880,26 @@ fn cmd_config_test_key(provider: &str) { } } +/// Try to store a credential in the vault first; silently falls through if vault +/// is not initialized or cannot be unlocked. The caller should always also +/// write to dotenv as a fallback. +fn save_credential_prefer_vault(env_var: &str, value: &str) { + use zeroize::Zeroizing; + + let home = openfang_home(); + let vault_path = home.join("vault.enc"); + if !vault_path.exists() { + return; + } + let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path); + if vault.unlock().is_err() { + return; + } + if let Ok(()) = vault.set(env_var.to_string(), Zeroizing::new(value.to_string())) { + println!(" {}", "Also stored in encrypted vault".dimmed()); + } +} + // --------------------------------------------------------------------------- // Quick chat (OpenClaw alias) // --------------------------------------------------------------------------- diff --git a/crates/openfang-extensions/src/credentials.rs b/crates/openfang-extensions/src/credentials.rs index ef10ed22..c7950387 100644 --- a/crates/openfang-extensions/src/credentials.rs +++ b/crates/openfang-extensions/src/credentials.rs @@ -126,6 +126,17 @@ impl CredentialResolver { )) } } + + /// Remove a credential from the vault (if available). + pub fn remove_from_vault(&mut self, key: &str) -> ExtensionResult { + if let Some(ref mut vault) = self.vault { + vault.remove(key) + } else { + Err(crate::ExtensionError::Vault( + "No vault configured".to_string(), + )) + } + } } /// Load a dotenv file into a HashMap. diff --git a/crates/openfang-kernel/Cargo.toml b/crates/openfang-kernel/Cargo.toml index dbbb27da..c9176ef2 100644 --- a/crates/openfang-kernel/Cargo.toml +++ b/crates/openfang-kernel/Cargo.toml @@ -34,6 +34,7 @@ rand = { workspace = true } hex = { workspace = true } reqwest = { workspace = true } cron = "0.15" +zeroize = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 83231dc7..573e7c6f 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -117,6 +117,8 @@ pub struct OpenFangKernel { Option>, /// Hand registry — curated autonomous capability packages. pub hand_registry: openfang_hands::registry::HandRegistry, + /// Credential resolver — vault → dotenv → env var priority chain. + pub credential_resolver: std::sync::Mutex, /// Extension/integration registry (bundled MCP templates + install state). pub extension_registry: std::sync::RwLock, /// Integration health monitor. @@ -562,16 +564,43 @@ impl OpenFangKernel { .map_err(|e| KernelError::BootFailed(format!("Memory init failed: {e}")))?, ); + // Initialize credential resolver (vault → dotenv → env var) + let credential_resolver = { + let vault_path = config.home_dir.join("vault.enc"); + let vault = if vault_path.exists() { + let mut v = openfang_extensions::vault::CredentialVault::new(vault_path); + match v.unlock() { + Ok(()) => { + info!("Credential vault unlocked ({} entries)", v.len()); + Some(v) + } + Err(e) => { + warn!("Credential vault exists but could not unlock: {e} — falling back to env vars"); + None + } + } + } else { + None + }; + let dotenv_path = config.home_dir.join(".env"); + openfang_extensions::credentials::CredentialResolver::new( + vault, + Some(&dotenv_path), + ) + }; + // Create LLM driver. - // For the API key, try: 1) explicit api_key_env from config, 2) provider_api_keys - // mapping, 3) auth profiles, 4) convention {PROVIDER}_API_KEY. This ensures - // custom providers (e.g. nvidia, azure) work without hardcoded env var names. - let default_api_key = if !config.default_model.api_key_env.is_empty() { - std::env::var(&config.default_model.api_key_env).ok() - } else { - // api_key_env not set — resolve using provider_api_keys / convention - let env_var = config.resolve_api_key_env(&config.default_model.provider); - std::env::var(&env_var).ok() + // For the API key, try: 1) credential resolver (vault → dotenv → env var), + // 2) provider_api_keys mapping, 3) convention {PROVIDER}_API_KEY. + let default_api_key = { + let env_var = if !config.default_model.api_key_env.is_empty() { + config.default_model.api_key_env.clone() + } else { + config.resolve_api_key_env(&config.default_model.provider) + }; + credential_resolver + .resolve(&env_var) + .map(|z: zeroize::Zeroizing| z.to_string()) }; let driver_config = DriverConfig { provider: config.default_model.provider.clone(), @@ -600,7 +629,7 @@ impl OpenFangKernel { if let Some((provider, model, env_var)) = drivers::detect_available_provider() { let auto_config = DriverConfig { provider: provider.to_string(), - api_key: std::env::var(env_var).ok(), + api_key: credential_resolver.resolve(env_var).map(|z: zeroize::Zeroizing| z.to_string()), base_url: config.provider_urls.get(provider).cloned(), skip_permissions: true, }; @@ -633,12 +662,13 @@ impl OpenFangKernel { model_chain.push((d.clone(), String::new())); } for fb in &config.fallback_providers { - let fb_api_key = if !fb.api_key_env.is_empty() { - std::env::var(&fb.api_key_env).ok() - } else { - // Resolve using provider_api_keys / convention for custom providers - let env_var = config.resolve_api_key_env(&fb.provider); - std::env::var(&env_var).ok() + let fb_api_key = { + let env_var = if !fb.api_key_env.is_empty() { + fb.api_key_env.clone() + } else { + config.resolve_api_key_env(&fb.provider) + }; + credential_resolver.resolve(&env_var).map(|z: zeroize::Zeroizing| z.to_string()) }; let fb_config = DriverConfig { provider: fb.provider.clone(), @@ -994,6 +1024,7 @@ impl OpenFangKernel { pairing, embedding_driver, hand_registry, + credential_resolver: std::sync::Mutex::new(credential_resolver), extension_registry: std::sync::RwLock::new(extension_registry), extension_health, effective_mcp_servers: std::sync::RwLock::new(all_mcp_servers), @@ -3938,16 +3969,23 @@ impl OpenFangKernel { .await { Ok(Ok(result)) => { - tracing::info!(job = %job_name, "Cron job completed successfully"); - kernel.cron_scheduler.record_success(job_id); - // Deliver response to configured channel - cron_deliver_response( + match cron_deliver_response( &kernel, agent_id, &result.response, &delivery, ) - .await; + .await + { + Ok(()) => { + tracing::info!(job = %job_name, "Cron job completed successfully"); + kernel.cron_scheduler.record_success(job_id); + } + Err(e) => { + tracing::warn!(job = %job_name, error = %e, "Cron job delivery failed"); + kernel.cron_scheduler.record_failure(job_id, &e); + } + } } Ok(Err(e)) => { let err_msg = format!("{e}"); @@ -3997,15 +4035,23 @@ impl OpenFangKernel { .await { Ok(Ok((_run_id, output))) => { - tracing::info!(job = %job_name, "Cron workflow completed"); - kernel.cron_scheduler.record_success(job_id); - cron_deliver_response( + match cron_deliver_response( &kernel, agent_id, &output, &delivery, ) - .await; + .await + { + Ok(()) => { + tracing::info!(job = %job_name, "Cron workflow completed"); + kernel.cron_scheduler.record_success(job_id); + } + Err(e) => { + tracing::warn!(job = %job_name, error = %e, "Cron workflow delivery failed"); + kernel.cron_scheduler.record_failure(job_id, &e); + } + } } Ok(Err(e)) => { let err_msg = format!("{e}"); @@ -4322,6 +4368,37 @@ impl OpenFangKernel { /// stored in the model catalog but NOT in `self.config.provider_urls` (which is /// the boot-time snapshot). This helper checks both sources so that custom /// providers work immediately without a daemon restart. + /// Resolve a credential by env var name using the vault → dotenv → env var chain. + pub fn resolve_credential(&self, key: &str) -> Option { + self.credential_resolver + .lock() + .unwrap_or_else(|e| e.into_inner()) + .resolve(key) + .map(|z| z.to_string()) + } + + /// Store a credential in the vault (best-effort — falls through silently if no vault). + pub fn store_credential(&self, key: &str, value: &str) { + let mut resolver = self + .credential_resolver + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Err(e) = resolver.store_in_vault(key, zeroize::Zeroizing::new(value.to_string())) { + debug!("Vault store skipped for {key}: {e}"); + } + } + + /// Remove a credential from the vault (best-effort — falls through silently if no vault). + pub fn remove_credential(&self, key: &str) { + let mut resolver = self + .credential_resolver + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Err(e) = resolver.remove_from_vault(key) { + debug!("Vault remove skipped for {key}: {e}"); + } + } + fn lookup_provider_url(&self, provider: &str) -> Option { // 1. Boot-time config (from config.toml [provider_urls]) if let Some(url) = self.config.provider_urls.get(provider) { @@ -4357,33 +4434,26 @@ impl OpenFangKernel { let has_custom_key = manifest.model.api_key_env.is_some(); let has_custom_url = manifest.model.base_url.is_some(); - // Always create a fresh driver by reading current env vars. - // This ensures API keys saved at runtime (via dashboard POST - // /api/providers/{name}/key which calls std::env::set_var) are - // picked up immediately — the boot-time default_driver cache is - // only used as a final fallback when driver creation fails. + // Always create a fresh driver by resolving credentials from the + // vault → dotenv → env var chain. This ensures API keys saved at + // runtime (via dashboard or vault) are picked up immediately. let primary = { let api_key = if has_custom_key { - // Agent explicitly set an API key env var — use it manifest .model .api_key_env .as_ref() - .and_then(|env| std::env::var(env).ok()) + .and_then(|env| self.resolve_credential(env)) } else if agent_provider == default_provider { - // Same provider as effective default — use its env var if !effective_default.api_key_env.is_empty() { - std::env::var(&effective_default.api_key_env).ok() + self.resolve_credential(&effective_default.api_key_env) } else { let env_var = self.config.resolve_api_key_env(agent_provider); - std::env::var(&env_var).ok() + self.resolve_credential(&env_var) } } else { - // Different provider — check auth profiles, provider_api_keys, - // and convention-based env var. For custom providers (not in the - // hardcoded list), this is the primary path for API key resolution. let env_var = self.config.resolve_api_key_env(agent_provider); - std::env::var(&env_var).ok() + self.resolve_credential(&env_var) }; // Don't inherit default provider's base_url when switching providers. @@ -5315,15 +5385,15 @@ async fn cron_deliver_response( agent_id: AgentId, response: &str, delivery: &openfang_types::scheduler::CronDelivery, -) { +) -> Result<(), String> { use openfang_types::scheduler::CronDelivery; if response.is_empty() { - return; + return Ok(()); } match delivery { - CronDelivery::None => {} + CronDelivery::None => Ok(()), CronDelivery::Channel { channel, to } => { tracing::debug!(channel = %channel, to = %to, "Cron: delivering to channel"); // Persist as last channel for this agent (survives restarts) @@ -5331,6 +5401,17 @@ async fn cron_deliver_response( let _ = kernel .memory .structured_set(agent_id, "delivery.last_channel", kv_val); + // Deliver via the registered channel adapter + kernel + .send_channel_message(channel, to, response, None) + .await + .map(|_| { + tracing::info!(channel = %channel, to = %to, "Cron: delivered to channel"); + }) + .map_err(|e| { + tracing::warn!(channel = %channel, to = %to, error = %e, "Cron channel delivery failed"); + format!("channel delivery failed: {e}") + }) } CronDelivery::LastChannel => { match kernel @@ -5341,15 +5422,23 @@ async fn cron_deliver_response( let channel = val["channel"].as_str().unwrap_or(""); let recipient = val["recipient"].as_str().unwrap_or(""); if !channel.is_empty() && !recipient.is_empty() { - tracing::info!( - channel = %channel, - recipient = %recipient, - "Cron: delivering to last channel" - ); + kernel + .send_channel_message(channel, recipient, response, None) + .await + .map(|_| { + tracing::info!(channel = %channel, recipient = %recipient, "Cron: delivered to last channel"); + }) + .map_err(|e| { + tracing::warn!(channel = %channel, recipient = %recipient, error = %e, "Cron last-channel delivery failed"); + format!("last-channel delivery failed: {e}") + }) + } else { + Ok(()) } } _ => { tracing::debug!("Cron: no last channel found for agent {}", agent_id); + Ok(()) } } } @@ -5357,22 +5446,24 @@ async fn cron_deliver_response( tracing::debug!(url = %url, "Cron: delivering via webhook"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) - .build(); - if let Ok(client) = client { - let payload = serde_json::json!({ - "agent_id": agent_id.to_string(), - "response": response, - "timestamp": chrono::Utc::now().to_rfc3339(), - }); - match client.post(url).json(&payload).send().await { - Ok(resp) => { - tracing::debug!(status = %resp.status(), "Cron webhook delivered"); - } - Err(e) => { - tracing::warn!(error = %e, "Cron webhook delivery failed"); - } - } - } + .build() + .map_err(|e| format!("webhook client init failed: {e}"))?; + let payload = serde_json::json!({ + "agent_id": agent_id.to_string(), + "response": response, + "timestamp": chrono::Utc::now().to_rfc3339(), + }); + let resp = client + .post(url) + .json(&payload) + .send() + .await + .map_err(|e| { + tracing::warn!(error = %e, "Cron webhook delivery failed"); + format!("webhook delivery failed: {e}") + })?; + tracing::debug!(status = %resp.status(), "Cron webhook delivered"); + Ok(()) } } }