Skip to main content

systemprompt_models/
secrets_bootstrap.rs

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