diff --git a/interface/src/routes/Overview.tsx b/interface/src/routes/Overview.tsx index c852bc92c..a52496c17 100644 --- a/interface/src/routes/Overview.tsx +++ b/interface/src/routes/Overview.tsx @@ -119,6 +119,22 @@ export function Overview({liveStates, activeLinks}: OverviewProps) { return (
+ {providersData && !providersData.has_any && agents.length > 0 && ( +
+

+ Agents are configured, but no provider credentials are available. Add or unlock + secrets to bring agents online. +

+ + Open Secrets Settings + +
+ )} + {/* Full-screen topology */}
{overviewLoading ? ( diff --git a/src/api/agents.rs b/src/api/agents.rs index ce75842c1..fd4aa2392 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -1472,6 +1472,16 @@ pub(super) async fn instance_overview( let agent_id = agent_config.id.clone(); let Some(pool) = pools.get(&agent_id) else { + agents.push(AgentSummary { + id: agent_id, + channel_count: 0, + memory_total: 0, + cron_job_count: 0, + activity_sparkline: vec![0; 14], + last_activity_at: None, + last_bulletin_at: None, + profile: None, + }); continue; }; diff --git a/src/api/providers.rs b/src/api/providers.rs index 5722d76d3..f3c00b00d 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -348,7 +348,13 @@ pub(super) async fn get_providers( ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); let instance_dir = (**state.instance_dir.load()).clone(); + let secrets_store = state.secrets_store.load(); let openai_oauth_configured = crate::openai_auth::credentials_path(&instance_dir).exists(); + let env_set = |name: &str| { + std::env::var(name) + .ok() + .is_some_and(|value| !value.trim().is_empty()) + }; let ( anthropic, @@ -381,17 +387,39 @@ pub(super) async fn get_providers( .parse() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let resolve_value = |value: &str| -> Option { + if let Some(alias) = value.strip_prefix("secret:") { + let store = secrets_store.as_ref().as_ref()?; + return match store.get(alias) { + Ok(secret) => Some(secret.expose().to_string()), + Err(error) => { + tracing::warn!(%error, alias, "failed to resolve secret reference"); + None + } + }; + } + + if let Some(var_name) = value.strip_prefix("env:") { + return std::env::var(var_name) + .ok() + .filter(|resolved| !resolved.trim().is_empty()); + } + + if value.trim().is_empty() { + None + } else { + Some(value.to_string()) + } + }; + let has_value = |key: &str, env_var: &str| -> bool { if let Some(llm) = doc.get("llm") && let Some(val) = llm.get(key) && let Some(s) = val.as_str() { - if let Some(var_name) = s.strip_prefix("env:") { - return std::env::var(var_name).is_ok(); - } - return !s.is_empty(); + return resolve_value(s).is_some(); } - std::env::var(env_var).is_ok() + env_set(env_var) }; ( @@ -421,28 +449,28 @@ pub(super) async fn get_providers( ) } else { ( - std::env::var("ANTHROPIC_API_KEY").is_ok(), - std::env::var("OPENAI_API_KEY").is_ok(), + env_set("ANTHROPIC_API_KEY"), + env_set("OPENAI_API_KEY"), openai_oauth_configured, - std::env::var("OPENROUTER_API_KEY").is_ok(), - std::env::var("KILO_API_KEY").is_ok(), - std::env::var("ZHIPU_API_KEY").is_ok(), - std::env::var("GROQ_API_KEY").is_ok(), - std::env::var("TOGETHER_API_KEY").is_ok(), - std::env::var("FIREWORKS_API_KEY").is_ok(), - std::env::var("DEEPSEEK_API_KEY").is_ok(), - std::env::var("XAI_API_KEY").is_ok(), - std::env::var("MISTRAL_API_KEY").is_ok(), - std::env::var("GEMINI_API_KEY").is_ok(), - std::env::var("OLLAMA_BASE_URL").is_ok() || std::env::var("OLLAMA_API_KEY").is_ok(), - std::env::var("OPENCODE_ZEN_API_KEY").is_ok(), - std::env::var("OPENCODE_GO_API_KEY").is_ok(), - std::env::var("NVIDIA_API_KEY").is_ok(), - std::env::var("MINIMAX_API_KEY").is_ok(), - std::env::var("MINIMAX_CN_API_KEY").is_ok(), - std::env::var("MOONSHOT_API_KEY").is_ok(), - std::env::var("ZAI_CODING_PLAN_API_KEY").is_ok(), - std::env::var("GITHUB_COPILOT_API_KEY").is_ok(), + env_set("OPENROUTER_API_KEY"), + env_set("KILO_API_KEY"), + env_set("ZHIPU_API_KEY"), + env_set("GROQ_API_KEY"), + env_set("TOGETHER_API_KEY"), + env_set("FIREWORKS_API_KEY"), + env_set("DEEPSEEK_API_KEY"), + env_set("XAI_API_KEY"), + env_set("MISTRAL_API_KEY"), + env_set("GEMINI_API_KEY"), + env_set("OLLAMA_BASE_URL") || env_set("OLLAMA_API_KEY"), + env_set("OPENCODE_ZEN_API_KEY"), + env_set("OPENCODE_GO_API_KEY"), + env_set("NVIDIA_API_KEY"), + env_set("MINIMAX_API_KEY"), + env_set("MINIMAX_CN_API_KEY"), + env_set("MOONSHOT_API_KEY"), + env_set("ZAI_CODING_PLAN_API_KEY"), + env_set("GITHUB_COPILOT_API_KEY"), ) }; diff --git a/src/api/secrets.rs b/src/api/secrets.rs index bc97fccd5..4d5d0ab6d 100644 --- a/src/api/secrets.rs +++ b/src/api/secrets.rs @@ -44,8 +44,10 @@ pub async fn secrets_status(State(state): State>) -> impl IntoResp Err(e) => return e.into_response(), }; - // TODO: detect platform_managed from deployment mode. - match store.status(false) { + let platform_managed = std::env::var("SPACEBOT_DEPLOYMENT") + .is_ok_and(|deployment| deployment.eq_ignore_ascii_case("hosted")); + + match store.status(platform_managed) { Ok(status) => Json(status).into_response(), Err(error) => ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/main.rs b/src/main.rs index 0679c27f3..100883116 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1344,31 +1344,91 @@ fn bootstrap_secrets_store( // Try to auto-unlock if encrypted. if store.is_encrypted() { let keystore = spacebot::secrets::keystore::platform_keystore(); + let tmpfs_paths = [ + std::path::Path::new("/run/spacebot/master_key"), + std::path::Path::new("/run/secrets/master_key"), + ]; // Hosted: check tmpfs-injected key. - let tmpfs_key_path = std::path::Path::new("/run/spacebot/master_key"); - let master_key = if tmpfs_key_path.exists() { - std::fs::read(tmpfs_key_path).ok().inspect(|key| { - if let Err(error) = std::fs::remove_file(tmpfs_key_path) { - tracing::warn!(%error, "failed to remove tmpfs master key — key may remain accessible"); + let tmpfs_master_key = tmpfs_paths.iter().find_map(|path| { + if !path.exists() { + return None; + } + + let raw_key = match std::fs::read(path) { + Ok(key) => key, + Err(error) => { + tracing::warn!(%error, path = %path.display(), "failed to read tmpfs master key"); + return None; } - if let Err(error) = keystore.store_key(KEYSTORE_INSTANCE_ID, key) { - tracing::warn!(%error, "failed to persist master key to OS credential store"); + }; + + // Platform currently stores keys as 64-char hex strings. Decode + // those to raw bytes before unlock; otherwise treat as raw bytes. + if let Ok(text) = std::str::from_utf8(&raw_key) { + let trimmed = text.trim(); + if trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return match hex::decode(trimmed) { + Ok(decoded) => Some(decoded), + Err(error) => { + tracing::warn!( + %error, + path = %path.display(), + "failed to decode hex tmpfs master key, falling back to raw bytes" + ); + Some(raw_key) + } + }; } - }) - } else { + } + + Some(raw_key) + }); + + let mut unlocked = false; + + if let Some(key) = tmpfs_master_key { + match store.unlock(&key) { + Ok(()) => { + unlocked = true; + if let Err(error) = keystore.store_key(KEYSTORE_INSTANCE_ID, &key) { + tracing::warn!(%error, "failed to persist master key to OS credential store"); + } + // Clean up tmpfs key files only after a successful unlock. + for cleanup_path in tmpfs_paths { + if cleanup_path.exists() + && let Err(error) = std::fs::remove_file(cleanup_path) + { + tracing::warn!( + %error, + path = %cleanup_path.display(), + "failed to remove tmpfs master key — key may remain accessible" + ); + } + } + } + Err(error) => { + tracing::warn!(%error, "failed to unlock secret store with tmpfs key"); + } + } + } + + if !unlocked { // Try instance-level key first, then fall back to legacy agent keys. - keystore + let master_key = keystore .load_key(KEYSTORE_INSTANCE_ID) .ok() .flatten() - .or_else(|| load_legacy_keystore_key(&instance_dir)) - }; + .or_else(|| load_legacy_keystore_key(&instance_dir)); - if let Some(key) = master_key - && let Err(error) = store.unlock(&key) - { - tracing::warn!(%error, "failed to unlock secret store — secrets will be inaccessible"); + if let Some(key) = master_key + && let Err(error) = store.unlock(&key) + { + tracing::warn!( + %error, + "failed to unlock secret store — secrets will be inaccessible" + ); + } } } @@ -1475,6 +1535,25 @@ fn has_provider_credentials( || spacebot::openai_auth::credentials_path(instance_dir).exists() } +fn configured_agent_infos(config: &spacebot::config::Config) -> Vec { + config + .resolve_agents() + .into_iter() + .map(|agent| spacebot::api::AgentInfo { + id: agent.id, + display_name: agent.display_name, + role: agent.role, + gradient_start: agent.gradient_start, + gradient_end: agent.gradient_end, + workspace: agent.workspace, + context_window: agent.context_window, + max_turns: agent.max_turns, + max_concurrent_branches: agent.max_concurrent_branches, + max_concurrent_workers: agent.max_concurrent_workers, + }) + .collect() +} + async fn run( config: spacebot::config::Config, foreground: bool, @@ -1519,6 +1598,12 @@ async fn run( api_state.auth_token = config.api.auth_token.clone(); let api_state = Arc::new(api_state); + // Keep the secrets API available in setup mode so encrypted stores can be + // unlocked before providers/agents are initialized. + if let Some(store) = &bootstrapped_store { + api_state.set_secrets_store(store.clone()); + } + // Start background update checker spacebot::update::spawn_update_checker(api_state.update_status.clone()); @@ -1637,6 +1722,7 @@ async fn run( api_state.set_agent_links((**agent_links.load()).clone()); api_state.set_agent_groups(config.groups.clone()); api_state.set_agent_humans(config.humans.clone()); + api_state.set_agent_configs(configured_agent_infos(&config)); // Track whether agents have been initialized let mut agents_initialized = false; @@ -2217,9 +2303,10 @@ async fn run( }; match new_config { - Ok(new_config) - if has_provider_credentials(&new_config.llm, &new_config.instance_dir) => - { + Ok(new_config) => { + api_state.set_agent_configs(configured_agent_infos(&new_config)); + + if has_provider_credentials(&new_config.llm, &new_config.instance_dir) { // Refresh in-memory defaults so newly created agents // inherit the latest routing from the updated config. api_state.set_defaults_config(new_config.defaults.clone()).await; @@ -2296,9 +2383,9 @@ async fn run( tracing::error!(%error, "failed to create LLM manager with new keys"); } } - } - Ok(_) => { - tracing::warn!("config reloaded but still no providers configured"); + } else { + tracing::warn!("config reloaded but still no providers configured"); + } } Err(error) => { tracing::error!(%error, "failed to reload config after provider setup");