Skip to main content

bamboo_server/
config_manager.rs

1//! Configuration patching helpers.
2//!
3//! The server has multiple endpoints that update different "sections" of the unified `config.json`
4//! (provider, proxy, setup, mcp, etc). These helpers keep patch application consistent and safe:
5//! - sanitize incoming patches (never accept encrypted secret material from clients)
6//! - preserve masked API keys (UI sends placeholders)
7//! - compute which runtime side-effects should run (reload provider / reconcile MCP)
8//!
9//! Pure domain logic (domain types, sanitization, merge) lives in
10//! `bamboo_infrastructure::patch`. This module keeps the infrastructure-coupled
11//! orchestration functions.
12//!
13//! Important design note:
14//! - `/v1/bamboo/config` is a *permissive* config management endpoint used by setup/UX flows.
15//!   It should allow persisting partial config even when the currently-selected provider is
16//!   not fully configured yet.
17//! - Strict provider validation belongs in provider-specific endpoints like
18//!   `/v1/bamboo/settings/provider` (and explicit reload/apply actions).
19
20use serde_json::{Map, Value};
21
22use crate::error::AppError;
23use bamboo_config::patch::ProviderApiKeyIntents;
24use bamboo_llm::Config;
25
26// Re-export pure domain logic from the config crate so server consumers
27// can import through `config_manager`.
28pub 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                    // Never encrypt/persist an env-sourced key — mirrors
43                    // refresh_provider_api_keys_encrypted's guard (#253). Without
44                    // it, an explicit `api_key: ""` clear of an env-sourced provider
45                    // (which preserve_env_sourced_provider_keys refills with the env
46                    // secret + api_key_from_env=true) would bake that plaintext
47                    // secret into config.json here. An explicit NEW key resets
48                    // api_key_from_env=false, so a real override still persists. #373.
49                    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                    // Skip env-sourced keys (see openai above). #373.
66                    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                    // Skip env-sourced keys (see openai above). #373.
83                    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    // The serde round-trip above drops every provider's `#[serde(skip_serializing)]`
160    // `api_key`; hydration only restores ciphertext-backed keys, so an env-sourced
161    // key (no ciphertext, #253) would be silently blanked by any settings PATCH.
162    // Copy env-sourced keys back from the live `current` config. #373.
163    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    // #373: an explicit `api_key: ""` clear of an env-sourced provider must NOT
186    // bake the env secret into config.json. build_merged_config restores the live
187    // env key (api_key_from_env=true), and the from_env guard in
188    // sync_provider_api_keys_encrypted_for_patch must then skip encrypting it.
189    #[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(&current, 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    // A genuine NEW key for an env-sourced provider resets api_key_from_env=false
213    // and IS persisted (the explicit override wins).
214    #[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(&current, 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    // A PATCH that doesn't touch api_key must not drop the env-sourced key.
234    #[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(&current, 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}