systemprompt_models/
secrets_bootstrap.rs1use 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}