Skip to main content

systemprompt_models/
secrets_bootstrap.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::Path;
4
5use crate::paths::constants::env_vars;
6use crate::profile::{SecretsSource, SecretsValidationMode, resolve_with_home};
7use crate::profile_bootstrap::ProfileBootstrap;
8use crate::secrets::{JWT_SECRET_MIN_LENGTH, SECRETS, Secrets};
9
10#[derive(Debug, Clone, Copy)]
11pub struct SecretsBootstrap;
12
13#[derive(Debug, thiserror::Error)]
14pub enum SecretsBootstrapError {
15    #[error(
16        "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
17    )]
18    NotInitialized,
19
20    #[error("Secrets already initialized")]
21    AlreadyInitialized,
22
23    #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
24    ProfileNotInitialized,
25
26    #[error("Secrets file not found: {path}")]
27    FileNotFound { path: String },
28
29    #[error("Invalid secrets file: {message}")]
30    InvalidSecretsFile { message: String },
31
32    #[error("No secrets configured. Create a secrets.json file.")]
33    NoSecretsConfigured,
34
35    #[error(
36        "JWT secret is required. Add 'jwt_secret' to your secrets file or set JWT_SECRET \
37         environment variable."
38    )]
39    JwtSecretRequired,
40
41    #[error(
42        "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
43         environment variable."
44    )]
45    DatabaseUrlRequired,
46}
47
48impl SecretsBootstrap {
49    pub fn init() -> Result<&'static Secrets> {
50        if SECRETS.get().is_some() {
51            anyhow::bail!(SecretsBootstrapError::AlreadyInitialized);
52        }
53
54        let secrets = Self::load_from_profile_config()?;
55
56        Self::log_loaded_secrets(&secrets);
57
58        SECRETS
59            .set(secrets)
60            .map_err(|_| anyhow::anyhow!(SecretsBootstrapError::AlreadyInitialized))?;
61
62        SECRETS
63            .get()
64            .ok_or_else(|| anyhow::anyhow!(SecretsBootstrapError::NotInitialized))
65    }
66
67    pub fn jwt_secret() -> Result<&'static str, SecretsBootstrapError> {
68        Ok(&Self::get()?.jwt_secret)
69    }
70
71    pub fn manifest_signing_secret_seed() -> Result<[u8; 32], SecretsBootstrapError> {
72        use sha2::{Digest, Sha256};
73        const DOMAIN_SEPARATOR: &[u8] = b"systemprompt-cowork-manifest-ed25519-v1";
74        let secret = Self::jwt_secret()?;
75        let mut hasher = Sha256::new();
76        hasher.update(DOMAIN_SEPARATOR);
77        hasher.update(secret.as_bytes());
78        Ok(hasher.finalize().into())
79    }
80
81    pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
82        Ok(&Self::get()?.database_url)
83    }
84
85    pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
86        Ok(Self::get()?.database_write_url.as_deref())
87    }
88
89    fn load_from_env() -> Result<Secrets> {
90        let jwt_secret = std::env::var("JWT_SECRET")
91            .ok()
92            .filter(|s| !s.is_empty())
93            .ok_or(SecretsBootstrapError::JwtSecretRequired)?;
94
95        let database_url = std::env::var("DATABASE_URL")
96            .ok()
97            .filter(|s| !s.is_empty())
98            .ok_or(SecretsBootstrapError::DatabaseUrlRequired)?;
99
100        let custom = std::env::var(env_vars::CUSTOM_SECRETS)
101            .ok()
102            .filter(|s| !s.is_empty())
103            .map_or_else(HashMap::new, |keys| {
104                keys.split(',')
105                    .filter_map(|key| {
106                        let key = key.trim();
107                        std::env::var(key)
108                            .ok()
109                            .filter(|v| !v.is_empty())
110                            .map(|v| (key.to_owned(), v))
111                    })
112                    .collect()
113            });
114
115        let secrets = Secrets {
116            jwt_secret,
117            database_url,
118            database_write_url: std::env::var("DATABASE_WRITE_URL")
119                .ok()
120                .filter(|s| !s.is_empty()),
121            external_database_url: std::env::var("EXTERNAL_DATABASE_URL")
122                .ok()
123                .filter(|s| !s.is_empty()),
124            internal_database_url: std::env::var("INTERNAL_DATABASE_URL")
125                .ok()
126                .filter(|s| !s.is_empty()),
127            sync_token: std::env::var("SYNC_TOKEN").ok().filter(|s| !s.is_empty()),
128            gemini: std::env::var("GEMINI_API_KEY")
129                .ok()
130                .filter(|s| !s.is_empty()),
131            anthropic: std::env::var("ANTHROPIC_API_KEY")
132                .ok()
133                .filter(|s| !s.is_empty()),
134            openai: std::env::var("OPENAI_API_KEY")
135                .ok()
136                .filter(|s| !s.is_empty()),
137            github: std::env::var("GITHUB_TOKEN").ok().filter(|s| !s.is_empty()),
138            moonshot: std::env::var("MOONSHOT_API_KEY")
139                .ok()
140                .or_else(|| std::env::var("KIMI_API_KEY").ok())
141                .filter(|s| !s.is_empty()),
142            qwen: std::env::var("QWEN_API_KEY")
143                .ok()
144                .or_else(|| std::env::var("DASHSCOPE_API_KEY").ok())
145                .filter(|s| !s.is_empty()),
146            custom,
147        };
148
149        secrets.validate()?;
150        Ok(secrets)
151    }
152
153    fn load_from_profile_config() -> Result<Secrets> {
154        let is_fly_environment = std::env::var("FLY_APP_NAME").is_ok();
155        let is_subprocess = std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok();
156
157        if is_subprocess || is_fly_environment {
158            if let Ok(jwt_secret) = std::env::var("JWT_SECRET") {
159                if jwt_secret.len() >= JWT_SECRET_MIN_LENGTH {
160                    tracing::debug!(
161                        "Using JWT_SECRET from environment (subprocess/container mode)"
162                    );
163                    return Self::load_from_env();
164                }
165            }
166        }
167
168        let profile =
169            ProfileBootstrap::get().map_err(|_| SecretsBootstrapError::ProfileNotInitialized)?;
170
171        let secrets_config = profile
172            .secrets
173            .as_ref()
174            .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
175
176        let is_fly_environment = std::env::var("FLY_APP_NAME").is_ok();
177
178        match secrets_config.source {
179            SecretsSource::Env if is_fly_environment => {
180                tracing::debug!("Loading secrets from environment (Fly.io container)");
181                Self::load_from_env()
182            },
183            SecretsSource::Env => {
184                tracing::debug!(
185                    "Profile source is 'env' but running locally, trying file first..."
186                );
187                Self::resolve_and_load_file(&secrets_config.secrets_path).or_else(|_| {
188                    tracing::debug!("File load failed, falling back to environment");
189                    Self::load_from_env()
190                })
191            },
192            SecretsSource::File => {
193                tracing::debug!("Loading secrets from file (profile source: file)");
194                Self::resolve_and_load_file(&secrets_config.secrets_path)
195                    .or_else(|e| Self::handle_load_error(e, secrets_config.validation))
196            },
197        }
198    }
199
200    fn handle_load_error(e: anyhow::Error, mode: SecretsValidationMode) -> Result<Secrets> {
201        log_secrets_issue(&e, mode);
202        Err(e)
203    }
204
205    pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
206        SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
207    }
208
209    pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
210        Self::get()
211    }
212
213    pub fn is_initialized() -> bool {
214        SECRETS.get().is_some()
215    }
216
217    pub fn try_init() -> Result<&'static Secrets> {
218        if SECRETS.get().is_some() {
219            return Self::get().map_err(Into::into);
220        }
221        Self::init()
222    }
223
224    fn resolve_and_load_file(path_str: &str) -> Result<Secrets> {
225        let profile_path = ProfileBootstrap::get_path()
226            .context("SYSTEMPROMPT_PROFILE not set - cannot resolve secrets path")?;
227
228        let profile_dir = Path::new(profile_path)
229            .parent()
230            .context("Invalid profile path - no parent directory")?;
231
232        let resolved_path = resolve_with_home(profile_dir, path_str);
233        Self::load_from_file(&resolved_path)
234    }
235
236    fn load_from_file(path: &Path) -> Result<Secrets> {
237        if !path.exists() {
238            anyhow::bail!(SecretsBootstrapError::FileNotFound {
239                path: path.display().to_string()
240            });
241        }
242
243        let content = std::fs::read_to_string(path)
244            .with_context(|| format!("Failed to read secrets file: {}", path.display()))?;
245
246        let secrets = Secrets::parse(&content).map_err(|e| {
247            anyhow::anyhow!(SecretsBootstrapError::InvalidSecretsFile {
248                message: e.to_string(),
249            })
250        })?;
251
252        tracing::debug!("Loaded secrets from {}", path.display());
253
254        Ok(secrets)
255    }
256
257    fn log_loaded_secrets(secrets: &Secrets) {
258        let message = build_loaded_secrets_message(secrets);
259        tracing::debug!("{}", message);
260    }
261}
262
263pub fn log_secrets_issue(e: &anyhow::Error, mode: SecretsValidationMode) {
264    match mode {
265        SecretsValidationMode::Warn => log_secrets_warn(e),
266        SecretsValidationMode::Skip => log_secrets_skip(e),
267        SecretsValidationMode::Strict => {},
268    }
269}
270
271pub fn log_secrets_warn(e: &anyhow::Error) {
272    tracing::warn!("Secrets file issue: {}", e);
273}
274
275pub fn log_secrets_skip(e: &anyhow::Error) {
276    tracing::debug!("Skipping secrets file: {}", e);
277}
278
279pub fn build_loaded_secrets_message(secrets: &Secrets) -> String {
280    let base = ["jwt_secret", "database_url"];
281    let optional_providers = [
282        secrets
283            .database_write_url
284            .as_ref()
285            .map(|_| "database_write_url"),
286        secrets
287            .external_database_url
288            .as_ref()
289            .map(|_| "external_database_url"),
290        secrets
291            .internal_database_url
292            .as_ref()
293            .map(|_| "internal_database_url"),
294        secrets.gemini.as_ref().map(|_| "gemini"),
295        secrets.anthropic.as_ref().map(|_| "anthropic"),
296        secrets.openai.as_ref().map(|_| "openai"),
297        secrets.github.as_ref().map(|_| "github"),
298    ];
299
300    let loaded: Vec<&str> = base
301        .into_iter()
302        .chain(optional_providers.into_iter().flatten())
303        .collect();
304
305    if secrets.custom.is_empty() {
306        format!("Loaded secrets: {}", loaded.join(", "))
307    } else {
308        format!(
309            "Loaded secrets: {}, {} custom",
310            loaded.join(", "),
311            secrets.custom.len()
312        )
313    }
314}