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