+
+ Agents are configured, but no provider credentials are available. Add or unlock
+ secrets to bring agents online.
+
+
+ Open Secrets Settings
+
+
{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");