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_infrastructure::patch::ProviderApiKeyIntents;
24use bamboo_infrastructure::Config;
25
26// Re-export pure domain logic from the config crate so server consumers
27// can import through `config_manager`.
28pub use bamboo_infrastructure::patch::{
29    deep_merge_json, domains_for_root_patch, effects_for_root_patch, is_masked_api_key,
30    preserve_masked_provider_api_keys, provider_api_key_intents, sanitize_root_patch,
31    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                    let api_key = openai.api_key.trim();
43                    openai.api_key_encrypted = if api_key.is_empty() {
44                        None
45                    } else {
46                        Some(
47                            bamboo_infrastructure::encryption::encrypt(api_key).map_err(|e| {
48                                AppError::InternalError(anyhow::anyhow!(
49                                    "Failed to encrypt OpenAI api_key: {e}"
50                                ))
51                            })?,
52                        )
53                    };
54                }
55            }
56            "anthropic" => {
57                if let Some(anthropic) = config.providers.anthropic.as_mut() {
58                    let api_key = anthropic.api_key.trim();
59                    anthropic.api_key_encrypted = if api_key.is_empty() {
60                        None
61                    } else {
62                        Some(
63                            bamboo_infrastructure::encryption::encrypt(api_key).map_err(|e| {
64                                AppError::InternalError(anyhow::anyhow!(
65                                    "Failed to encrypt Anthropic api_key: {e}"
66                                ))
67                            })?,
68                        )
69                    };
70                }
71            }
72            "gemini" => {
73                if let Some(gemini) = config.providers.gemini.as_mut() {
74                    let api_key = gemini.api_key.trim();
75                    gemini.api_key_encrypted = if api_key.is_empty() {
76                        None
77                    } else {
78                        Some(
79                            bamboo_infrastructure::encryption::encrypt(api_key).map_err(|e| {
80                                AppError::InternalError(anyhow::anyhow!(
81                                    "Failed to encrypt Gemini api_key: {e}"
82                                ))
83                            })?,
84                        )
85                    };
86                }
87            }
88            "bodhi" => {
89                if let Some(bodhi) = config.providers.bodhi.as_mut() {
90                    let api_key = bodhi.api_key.trim();
91                    bodhi.api_key_encrypted = if api_key.is_empty() {
92                        None
93                    } else {
94                        Some(
95                            bamboo_infrastructure::encryption::encrypt(api_key).map_err(|e| {
96                                AppError::InternalError(anyhow::anyhow!(
97                                    "Failed to encrypt Bodhi api_key: {e}"
98                                ))
99                            })?,
100                        )
101                    };
102                }
103            }
104            _ => {}
105        }
106    }
107
108    for instance_id in intents.provider_instances.iter() {
109        if let Some(instance) = config.provider_instances.get_mut(instance_id) {
110            let api_key = instance.api_key.trim();
111            instance.api_key_encrypted = if api_key.is_empty() {
112                None
113            } else {
114                Some(
115                    bamboo_infrastructure::encryption::encrypt(api_key).map_err(|e| {
116                        AppError::InternalError(anyhow::anyhow!(
117                            "Failed to encrypt provider instance api_key for '{instance_id}': {e}"
118                        ))
119                    })?,
120                )
121            };
122        }
123    }
124
125    Ok(())
126}
127
128pub fn assert_json_object(value: Value) -> Result<Map<String, Value>, AppError> {
129    match value {
130        Value::Object(map) => Ok(map),
131        _ => Err(AppError::BadRequest(
132            "config.json must be a JSON object".to_string(),
133        )),
134    }
135}
136
137pub fn build_merged_config(
138    current: &Config,
139    patch_obj: Map<String, Value>,
140) -> Result<Config, AppError> {
141    let mut merged = serde_json::to_value(current)
142        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to serialize config: {e}")))?;
143
144    deep_merge_json(&mut merged, Value::Object(patch_obj));
145
146    let mut new_config: Config = serde_json::from_value(merged)
147        .map_err(|e| AppError::BadRequest(format!("Invalid configuration JSON: {e}")))?;
148    new_config.hydrate_proxy_auth_from_encrypted();
149    new_config.hydrate_provider_api_keys_from_encrypted();
150    new_config.hydrate_provider_instance_api_keys_from_encrypted();
151    new_config.hydrate_mcp_secrets_from_encrypted();
152    new_config.hydrate_env_vars_from_encrypted();
153    new_config.normalize_tool_settings();
154    new_config.normalize_skill_settings();
155
156    Ok(new_config)
157}