Skip to main content

bamboo_infrastructure/config/
patch.rs

1//! Config patch domain logic.
2//!
3//! Pure business rules for interpreting, sanitizing, and merging
4//! partial config patches. Used by the server's config management endpoints.
5
6use serde_json::{Map, Value};
7
8use crate::Config;
9
10/// Detect whether a string value looks like a masked/placeholder API key.
11pub fn is_masked_api_key(value: &str) -> bool {
12    let v = value.trim();
13    // Empty string is treated as an explicit "clear" signal (we control all clients).
14    v.contains("***") || v.contains("...") || v == "****...****"
15}
16
17/// Extract provider names from a config patch that intend to set a new API key.
18/// Masked placeholders are ignored — they signal "keep existing key".
19pub fn provider_api_key_intents(
20    patch_obj: &Map<String, Value>,
21) -> std::collections::BTreeSet<String> {
22    let mut providers = std::collections::BTreeSet::new();
23    let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) else {
24        return providers;
25    };
26
27    for (provider_name, provider_patch) in root.iter() {
28        let Some(obj) = provider_patch.as_object() else {
29            continue;
30        };
31        let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
32            continue;
33        };
34        // Ignore masked placeholders; those are "preserve existing" signals from the UI.
35        if is_masked_api_key(api_key) {
36            continue;
37        }
38        providers.insert(provider_name.clone());
39    }
40
41    providers
42}
43
44/// Reload strategy to apply after a config patch.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ReloadMode {
47    None,
48    /// Attempt reload, but do not fail the request if reload fails.
49    BestEffort,
50    /// Reload must succeed; otherwise the request fails.
51    Strict,
52}
53
54/// Side-effects determined from a config patch.
55#[derive(Debug, Clone, Copy)]
56pub struct PatchEffects {
57    pub reload_provider: ReloadMode,
58    pub reconcile_mcp: bool,
59}
60
61/// Which config domains are touched by a patch.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct DomainChanges {
64    pub provider: bool,
65    pub proxy: bool,
66    pub setup: bool,
67    pub mcp: bool,
68    pub keyword_masking: bool,
69    pub hooks: bool,
70    pub model_mapping: bool,
71}
72
73/// Classify which config domains are affected by a patch.
74pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
75    let mut changes = DomainChanges::default();
76
77    for key in patch_obj.keys() {
78        match key.as_str() {
79            // Provider domain
80            "provider" | "providers" | "model" => changes.provider = true,
81
82            // Proxy domain
83            "http_proxy"
84            | "https_proxy"
85            | "proxy_auth"
86            | "proxy_auth_encrypted"
87            | "http_proxy_auth_encrypted"
88            | "https_proxy_auth_encrypted" => changes.proxy = true,
89
90            // Setup domain (stored under Config.extra via serde flatten)
91            "setup" => changes.setup = true,
92
93            // MCP domain
94            "mcp" | "mcpServers" => changes.mcp = true,
95
96            // Other known config domains
97            "keyword_masking" => changes.keyword_masking = true,
98            "hooks" => changes.hooks = true,
99            "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
100
101            _ => {}
102        }
103    }
104
105    changes
106}
107
108/// Determine what side-effects a config patch should trigger.
109pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
110    let domains = domains_for_root_patch(patch_obj);
111
112    let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
113    let touches_proxy = domains.proxy;
114    let touches_mcp = domains.mcp;
115
116    PatchEffects {
117        reload_provider: if touches_provider || touches_proxy {
118            ReloadMode::BestEffort
119        } else {
120            ReloadMode::None
121        },
122        // SSE-based MCP servers are HTTP clients and must respect proxy settings.
123        // Reconcile so proxy changes take effect without a restart.
124        reconcile_mcp: touches_mcp || touches_proxy,
125    }
126}
127
128/// Remove forbidden fields from a config patch before application.
129///
130/// Strips encrypted auth material, data_dir, and MCP secret fields
131/// that should never be set directly by clients.
132pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
133    // Never allow clients to modify proxy auth fields or data_dir via this endpoint.
134    patch_obj.remove("proxy_auth");
135    patch_obj.remove("proxy_auth_encrypted");
136    // Legacy/compat proxy auth keys (written by older Bodhi/Tauri builds).
137    patch_obj.remove("http_proxy_auth_encrypted");
138    patch_obj.remove("https_proxy_auth_encrypted");
139    patch_obj.remove("data_dir");
140
141    // Never allow clients to set encrypted key material directly.
142    if let Some(providers) = patch_obj
143        .get_mut("providers")
144        .and_then(|v| v.as_object_mut())
145    {
146        for (_provider_name, provider_cfg) in providers.iter_mut() {
147            let Some(obj) = provider_cfg.as_object_mut() else {
148                continue;
149            };
150            obj.remove("api_key_encrypted");
151        }
152    }
153
154    // Never allow clients to set encrypted secret material directly.
155    //
156    // Canonical MCP format:
157    //   "mcpServers": { "<id>": { env_encrypted, headers[*].value_encrypted, ... } }
158    if let Some(mcp_servers) = patch_obj
159        .get_mut("mcpServers")
160        .and_then(|v| v.as_object_mut())
161    {
162        for (_id, server) in mcp_servers.iter_mut() {
163            let Some(server_obj) = server.as_object_mut() else {
164                continue;
165            };
166            server_obj.remove("env_encrypted");
167            if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
168                for header in headers.iter_mut() {
169                    let Some(header_obj) = header.as_object_mut() else {
170                        continue;
171                    };
172                    header_obj.remove("value_encrypted");
173                }
174            }
175        }
176    }
177
178    // Legacy MCP shape:
179    //   "mcp": { "servers": [ { transport: { env_encrypted / headers[*].value_encrypted } } ] }
180    if let Some(servers) = patch_obj
181        .get_mut("mcp")
182        .and_then(|m| m.get_mut("servers"))
183        .and_then(|v| v.as_array_mut())
184    {
185        for server in servers.iter_mut() {
186            let Some(server_obj) = server.as_object_mut() else {
187                continue;
188            };
189            let Some(transport) = server_obj
190                .get_mut("transport")
191                .and_then(|v| v.as_object_mut())
192            else {
193                continue;
194            };
195
196            match transport.get("type").and_then(|v| v.as_str()) {
197                Some("stdio") => {
198                    transport.remove("env_encrypted");
199                }
200                Some("sse") => {
201                    if let Some(headers) =
202                        transport.get_mut("headers").and_then(|v| v.as_array_mut())
203                    {
204                        for header in headers.iter_mut() {
205                            let Some(header_obj) = header.as_object_mut() else {
206                                continue;
207                            };
208                            header_obj.remove("value_encrypted");
209                        }
210                    }
211                }
212                _ => {}
213            }
214        }
215    }
216}
217
218/// Replace masked API key placeholders in a patch with the current config's plain keys.
219///
220/// The UI sends masked values (e.g. `****...****`) to indicate "do not change this key".
221/// This function resolves those back to the existing plain-text key from the live config.
222pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
223    let Some(patch_providers) = patch_obj
224        .get_mut("providers")
225        .and_then(|v| v.as_object_mut())
226    else {
227        return;
228    };
229
230    for (provider_name, provider_patch) in patch_providers.iter_mut() {
231        let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
232            continue;
233        };
234
235        let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
236            continue;
237        };
238        if !is_masked_api_key(api_key) {
239            continue;
240        }
241
242        let existing_plain = match provider_name.as_str() {
243            "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
244            "anthropic" => current
245                .providers
246                .anthropic
247                .as_ref()
248                .map(|c| c.api_key.clone()),
249            "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
250            _ => None,
251        };
252
253        if let Some(existing_plain) = existing_plain {
254            if !existing_plain.trim().is_empty() {
255                patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
256            } else {
257                patch_cfg_obj.remove("api_key");
258            }
259        } else {
260            patch_cfg_obj.remove("api_key");
261        }
262    }
263}
264
265/// Deep merge `src` into `dst`, recursively combining objects and replacing leaf values.
266pub fn deep_merge_json(dst: &mut Value, src: Value) {
267    match (dst, src) {
268        (Value::Object(dst_map), Value::Object(src_map)) => {
269            for (key, value) in src_map {
270                match dst_map.get_mut(&key) {
271                    Some(existing) => deep_merge_json(existing, value),
272                    None => {
273                        dst_map.insert(key, value);
274                    }
275                }
276            }
277        }
278        (dst_slot, src_value) => {
279            *dst_slot = src_value;
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use serde_json::json;
288
289    #[test]
290    fn domains_for_root_patch_detects_proxy_and_provider() {
291        let patch = json!({
292            "provider": "openai",
293            "http_proxy": "http://proxy:8080",
294            "setup": { "completed": false },
295            "mcpServers": {}
296        });
297
298        let domains = domains_for_root_patch(patch.as_object().unwrap());
299        assert!(domains.provider);
300        assert!(domains.proxy);
301        assert!(domains.setup);
302        assert!(domains.mcp);
303    }
304
305    #[test]
306    fn provider_api_key_intents_ignores_masked_placeholders() {
307        let patch = json!({
308            "providers": {
309                "openai": { "api_key": "****...****" },
310                "gemini": { "api_key": "sk-real" }
311            }
312        });
313        let intents = provider_api_key_intents(patch.as_object().unwrap());
314        assert!(intents.contains("gemini"));
315        assert!(!intents.contains("openai"));
316    }
317}