systemprompt_config/bootstrap/secrets/
mod.rs1mod io;
9mod loader;
10mod logging;
11
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14
15use base64::Engine;
16use systemprompt_models::profile::resolve_with_home;
17use systemprompt_models::secrets::Secrets;
18
19use super::manifest::{MANIFEST_SIGNING_SEED_BYTES, decode_seed, generate_seed, persist_seed};
20use super::profile::ProfileBootstrap;
21use crate::error::{ConfigError, ConfigResult};
22
23pub use io::{handle_load_error, load_secrets_from_path};
24pub use logging::{
25 build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
26};
27
28static SECRETS: OnceLock<Secrets> = OnceLock::new();
29
30pub const JWT_SECRET_MIN_LENGTH: usize = 32;
31
32#[derive(Debug, Clone, Copy)]
33pub struct SecretsBootstrap;
34
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum SecretsBootstrapError {
38 #[error(
39 "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
40 )]
41 NotInitialized,
42
43 #[error("Secrets already initialized")]
44 AlreadyInitialized,
45
46 #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
47 ProfileNotInitialized,
48
49 #[error("Secrets file not found: {path}")]
50 FileNotFound { path: String },
51
52 #[error("Invalid secrets file: {message}")]
53 InvalidSecretsFile { message: String },
54
55 #[error("No secrets configured. Create a secrets.json file.")]
56 NoSecretsConfigured,
57
58 #[error(
59 "JWT secret is required. Add 'jwt_secret' to your secrets file or set JWT_SECRET \
60 environment variable."
61 )]
62 JwtSecretRequired,
63
64 #[error(
65 "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
66 environment variable."
67 )]
68 DatabaseUrlRequired,
69
70 #[error(
71 "manifest_signing_secret_seed is missing from the secrets file and the bootstrap path is \
72 not writable. Run `systemprompt admin cowork rotate-signing-key` against a writable \
73 secrets file, or add a base64-encoded 32-byte value under `manifest_signing_secret_seed`."
74 )]
75 ManifestSeedUnavailable,
76
77 #[error("manifest_signing_secret_seed is invalid: {message}")]
78 ManifestSeedInvalid { message: String },
79
80 #[error(
81 "manifest_signing_secret_seed missing in subprocess env — parent must propagate \
82 MANIFEST_SIGNING_SECRET_SEED so subprocesses don't regenerate and clobber the secrets \
83 file"
84 )]
85 SubprocessSeedMissing,
86}
87
88impl SecretsBootstrap {
89 pub fn init() -> ConfigResult<&'static Secrets> {
90 if SECRETS.get().is_some() {
91 return Err(SecretsBootstrapError::AlreadyInitialized.into());
92 }
93
94 let mut secrets = loader::load_from_profile_config()?;
95 Self::ensure_manifest_signing_seed(&mut secrets)?;
96
97 Self::log_loaded_secrets(&secrets);
98
99 SECRETS
100 .set(secrets)
101 .map_err(|_| SecretsBootstrapError::AlreadyInitialized)?;
102
103 SECRETS
104 .get()
105 .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
106 }
107
108 pub fn jwt_secret() -> Result<&'static str, SecretsBootstrapError> {
109 Ok(&Self::get()?.jwt_secret)
110 }
111
112 pub fn manifest_signing_secret_seed()
113 -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
114 let encoded = Self::get()?
115 .manifest_signing_secret_seed
116 .as_deref()
117 .ok_or(SecretsBootstrapError::ManifestSeedUnavailable)?;
118 decode_seed(encoded)
119 }
120
121 pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
122 let path = Self::resolved_secrets_file_path()?;
123 let seed = generate_seed();
124 persist_seed(&path, &seed)?;
125 Ok(seed)
126 }
127
128 fn ensure_manifest_signing_seed(secrets: &mut Secrets) -> ConfigResult<()> {
129 if let Some(encoded) = secrets.manifest_signing_secret_seed.as_deref() {
130 decode_seed(encoded)?;
131 return Ok(());
132 }
133 if std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok() {
134 return Err(SecretsBootstrapError::SubprocessSeedMissing.into());
135 }
136 let Ok(path) = Self::resolved_secrets_file_path() else {
137 tracing::warn!(
138 "manifest_signing_secret_seed missing and no writable secrets file is configured"
139 );
140 return Ok(());
141 };
142 if !path.exists() {
143 tracing::warn!(
144 path = %path.display(),
145 "manifest_signing_secret_seed missing and secrets file does not exist on disk"
146 );
147 return Ok(());
148 }
149 let seed = generate_seed();
150 persist_seed(&path, &seed)?;
151 secrets.manifest_signing_secret_seed =
152 Some(base64::engine::general_purpose::STANDARD.encode(seed));
153 tracing::info!(
154 path = %path.display(),
155 "Generated and persisted fresh manifest_signing_secret_seed"
156 );
157 Ok(())
158 }
159
160 fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
161 let profile =
162 ProfileBootstrap::get().map_err(|_| SecretsBootstrapError::ProfileNotInitialized)?;
163 let secrets_config = profile
164 .secrets
165 .as_ref()
166 .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
167 let profile_path = ProfileBootstrap::get_path()
168 .map_err(|_| SecretsBootstrapError::ProfileNotInitialized)?;
169 let profile_dir = Path::new(profile_path)
170 .parent()
171 .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
172 Ok(resolve_with_home(profile_dir, &secrets_config.secrets_path))
173 }
174
175 pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
176 Ok(&Self::get()?.database_url)
177 }
178
179 pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
180 Ok(Self::get()?.database_write_url.as_deref())
181 }
182
183 pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
184 SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
185 }
186
187 pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
188 Self::get()
189 }
190
191 #[must_use]
192 pub fn is_initialized() -> bool {
193 SECRETS.get().is_some()
194 }
195
196 pub fn try_init() -> ConfigResult<&'static Secrets> {
197 if SECRETS.get().is_some() {
198 return Self::get().map_err(Into::into);
199 }
200 Self::init()
201 }
202
203 fn log_loaded_secrets(secrets: &Secrets) {
204 let message = build_loaded_secrets_message(secrets);
205 tracing::debug!("{}", message);
206 }
207}