bamboo_server/
config_manager.rs1use serde_json::{Map, Value};
21
22use crate::error::AppError;
23use bamboo_config::patch::ProviderApiKeyIntents;
24use bamboo_llm::Config;
25
26pub use bamboo_config::patch::{
29 deep_merge_json, domains_for_root_patch, effects_for_root_patch, is_masked_api_key,
30 preserve_masked_notification_secrets, preserve_masked_provider_api_keys,
31 provider_api_key_intents, sanitize_root_patch, DomainChanges, PatchEffects, ReloadMode,
32};
33
34pub fn sync_provider_api_keys_encrypted_for_patch(
35 config: &mut Config,
36 intents: &ProviderApiKeyIntents,
37) -> Result<(), AppError> {
38 for name in intents.providers.iter() {
39 match name.as_str() {
40 "openai" => {
41 if let Some(openai) = config.providers.openai.as_mut() {
42 if !openai.api_key_from_env {
50 let api_key = openai.api_key.trim();
51 openai.api_key_encrypted = if api_key.is_empty() {
52 None
53 } else {
54 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
55 AppError::InternalError(anyhow::anyhow!(
56 "Failed to encrypt OpenAI api_key: {e}"
57 ))
58 })?)
59 };
60 }
61 }
62 }
63 "anthropic" => {
64 if let Some(anthropic) = config.providers.anthropic.as_mut() {
65 if !anthropic.api_key_from_env {
67 let api_key = anthropic.api_key.trim();
68 anthropic.api_key_encrypted = if api_key.is_empty() {
69 None
70 } else {
71 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
72 AppError::InternalError(anyhow::anyhow!(
73 "Failed to encrypt Anthropic api_key: {e}"
74 ))
75 })?)
76 };
77 }
78 }
79 }
80 "gemini" => {
81 if let Some(gemini) = config.providers.gemini.as_mut() {
82 if !gemini.api_key_from_env {
84 let api_key = gemini.api_key.trim();
85 gemini.api_key_encrypted = if api_key.is_empty() {
86 None
87 } else {
88 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
89 AppError::InternalError(anyhow::anyhow!(
90 "Failed to encrypt Gemini api_key: {e}"
91 ))
92 })?)
93 };
94 }
95 }
96 }
97 "bodhi" => {
98 if let Some(bodhi) = config.providers.bodhi.as_mut() {
99 let api_key = bodhi.api_key.trim();
100 bodhi.api_key_encrypted = if api_key.is_empty() {
101 None
102 } else {
103 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
104 AppError::InternalError(anyhow::anyhow!(
105 "Failed to encrypt Bodhi api_key: {e}"
106 ))
107 })?)
108 };
109 }
110 }
111 _ => {}
112 }
113 }
114
115 for instance_id in intents.provider_instances.iter() {
116 if let Some(instance) = config.provider_instances.get_mut(instance_id) {
117 let api_key = instance.api_key.trim();
118 instance.api_key_encrypted = if api_key.is_empty() {
119 None
120 } else {
121 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
122 AppError::InternalError(anyhow::anyhow!(
123 "Failed to encrypt provider instance api_key for '{instance_id}': {e}"
124 ))
125 })?)
126 };
127 }
128 }
129
130 Ok(())
131}
132
133pub fn assert_json_object(value: Value) -> Result<Map<String, Value>, AppError> {
134 match value {
135 Value::Object(map) => Ok(map),
136 _ => Err(AppError::BadRequest(
137 "config.json must be a JSON object".to_string(),
138 )),
139 }
140}
141
142pub fn build_merged_config(
143 current: &Config,
144 patch_obj: Map<String, Value>,
145) -> Result<Config, AppError> {
146 let mut merged = serde_json::to_value(current)
147 .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to serialize config: {e}")))?;
148
149 deep_merge_json(&mut merged, Value::Object(patch_obj));
150
151 let mut new_config: Config = serde_json::from_value(merged)
152 .map_err(|e| AppError::BadRequest(format!("Invalid configuration JSON: {e}")))?;
153 new_config.hydrate_proxy_auth_from_encrypted();
154 new_config.hydrate_provider_api_keys_from_encrypted();
155 new_config.hydrate_provider_instance_api_keys_from_encrypted();
156 new_config.hydrate_mcp_secrets_from_encrypted();
157 new_config.hydrate_env_vars_from_encrypted();
158 new_config.hydrate_notifications_from_encrypted();
159 new_config.preserve_env_sourced_provider_keys(current);
164 new_config.normalize_tool_settings();
165 new_config.normalize_skill_settings();
166
167 Ok(new_config)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use bamboo_config::OpenAIConfig;
174
175 fn env_sourced_openai_config() -> Config {
176 let mut config = Config::default();
177 config.providers.openai = Some(OpenAIConfig {
178 api_key: "sk-env-secret".to_string(),
179 api_key_from_env: true,
180 ..Default::default()
181 });
182 config
183 }
184
185 #[test]
190 fn clearing_env_sourced_key_does_not_persist_the_secret() {
191 let current = env_sourced_openai_config();
192 let patch: Map<String, Value> =
193 serde_json::from_str(r#"{"providers":{"openai":{"api_key":""}}}"#).unwrap();
194 let intents = provider_api_key_intents(&patch);
195 assert!(
196 intents.providers.contains("openai"),
197 "empty string is a clear intent"
198 );
199
200 let mut merged = build_merged_config(¤t, patch).expect("merge");
201 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
202
203 let openai = merged.providers.openai.as_ref().unwrap();
204 assert!(
205 openai.api_key_encrypted.is_none(),
206 "env secret must NOT be encrypted to disk on a clear"
207 );
208 assert!(openai.api_key_from_env, "still flagged env-sourced");
209 assert_eq!(openai.api_key, "sk-env-secret", "live env key preserved");
210 }
211
212 #[test]
215 fn explicit_new_key_overrides_env_and_persists() {
216 let current = env_sourced_openai_config();
217 let patch: Map<String, Value> =
218 serde_json::from_str(r#"{"providers":{"openai":{"api_key":"sk-brand-new"}}}"#).unwrap();
219 let intents = provider_api_key_intents(&patch);
220
221 let mut merged = build_merged_config(¤t, patch).expect("merge");
222 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
223
224 let openai = merged.providers.openai.as_ref().unwrap();
225 assert_eq!(openai.api_key, "sk-brand-new", "explicit override wins");
226 assert!(!openai.api_key_from_env, "override clears the env flag");
227 assert!(
228 openai.api_key_encrypted.is_some(),
229 "a real override is encrypted/persisted"
230 );
231 }
232
233 #[test]
235 fn unrelated_patch_preserves_env_key() {
236 let current = env_sourced_openai_config();
237 let patch: Map<String, Value> =
238 serde_json::from_str(r#"{"providers":{"openai":{"model":"gpt-x"}}}"#).unwrap();
239 let intents = provider_api_key_intents(&patch);
240 assert!(
241 !intents.providers.contains("openai"),
242 "no api_key in patch → no intent"
243 );
244
245 let mut merged = build_merged_config(¤t, patch).expect("merge");
246 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
247
248 let openai = merged.providers.openai.as_ref().unwrap();
249 assert_eq!(
250 openai.api_key, "sk-env-secret",
251 "env key preserved across unrelated patch"
252 );
253 assert!(openai.api_key_from_env);
254 assert!(openai.api_key_encrypted.is_none(), "still not persisted");
255 }
256}