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 API-key update intents from a config patch.
18///
19/// Masked placeholders are ignored — they signal "keep existing key".
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ProviderApiKeyIntents {
22    pub providers: std::collections::BTreeSet<String>,
23    pub provider_instances: std::collections::BTreeSet<String>,
24}
25
26pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
27    let mut intents = ProviderApiKeyIntents::default();
28
29    if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
30        for (provider_name, provider_patch) in root.iter() {
31            let Some(obj) = provider_patch.as_object() else {
32                continue;
33            };
34            let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
35                continue;
36            };
37            if is_masked_api_key(api_key) {
38                continue;
39            }
40            intents.providers.insert(provider_name.clone());
41        }
42    }
43
44    if let Some(root) = patch_obj
45        .get("provider_instances")
46        .and_then(|v| v.as_object())
47    {
48        for (instance_id, instance_patch) in root.iter() {
49            let Some(obj) = instance_patch.as_object() else {
50                continue;
51            };
52            let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
53                continue;
54            };
55            if is_masked_api_key(api_key) {
56                continue;
57            }
58            intents.provider_instances.insert(instance_id.clone());
59        }
60    }
61
62    intents
63}
64
65/// Reload strategy to apply after a config patch.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ReloadMode {
68    None,
69    /// Attempt reload, but do not fail the request if reload fails.
70    BestEffort,
71    /// Reload must succeed; otherwise the request fails.
72    Strict,
73}
74
75/// Side-effects determined from a config patch.
76#[derive(Debug, Clone, Copy)]
77pub struct PatchEffects {
78    pub reload_provider: ReloadMode,
79    pub reconcile_mcp: bool,
80}
81
82/// Which config domains are touched by a patch.
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub struct DomainChanges {
85    pub provider: bool,
86    pub proxy: bool,
87    pub setup: bool,
88    pub mcp: bool,
89    pub keyword_masking: bool,
90    pub hooks: bool,
91    pub model_mapping: bool,
92}
93
94/// Classify which config domains are affected by a patch.
95pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
96    let mut changes = DomainChanges::default();
97
98    for key in patch_obj.keys() {
99        match key.as_str() {
100            // Provider domain
101            "provider"
102            | "providers"
103            | "provider_instances"
104            | "default_provider_instance"
105            | "model"
106            | "defaults"
107            | "features" => changes.provider = true,
108
109            // Proxy domain
110            "http_proxy"
111            | "https_proxy"
112            | "proxy_auth"
113            | "proxy_auth_encrypted"
114            | "http_proxy_auth_encrypted"
115            | "https_proxy_auth_encrypted" => changes.proxy = true,
116
117            // Setup domain (stored under Config.extra via serde flatten)
118            "setup" => changes.setup = true,
119
120            // MCP domain
121            "mcp" | "mcpServers" => changes.mcp = true,
122
123            // Other known config domains
124            "keyword_masking" => changes.keyword_masking = true,
125            "hooks" => changes.hooks = true,
126            "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
127
128            _ => {}
129        }
130    }
131
132    changes
133}
134
135/// Determine what side-effects a config patch should trigger.
136pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
137    let domains = domains_for_root_patch(patch_obj);
138
139    let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
140    let touches_proxy = domains.proxy;
141    let touches_mcp = domains.mcp;
142
143    PatchEffects {
144        reload_provider: if touches_provider || touches_proxy {
145            ReloadMode::BestEffort
146        } else {
147            ReloadMode::None
148        },
149        // SSE-based MCP servers are HTTP clients and must respect proxy settings.
150        // Reconcile so proxy changes take effect without a restart.
151        reconcile_mcp: touches_mcp || touches_proxy,
152    }
153}
154
155/// Remove forbidden fields from a config patch before application.
156///
157/// Strips encrypted auth material, data_dir, and MCP secret fields
158/// that should never be set directly by clients.
159pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
160    // Never allow clients to modify proxy auth fields or data_dir via this endpoint.
161    patch_obj.remove("proxy_auth");
162    patch_obj.remove("proxy_auth_encrypted");
163    // Legacy/compat proxy auth keys (written by older Bodhi/Tauri builds).
164    patch_obj.remove("http_proxy_auth_encrypted");
165    patch_obj.remove("https_proxy_auth_encrypted");
166    patch_obj.remove("data_dir");
167
168    // Never allow clients to set encrypted key material directly.
169    if let Some(providers) = patch_obj
170        .get_mut("providers")
171        .and_then(|v| v.as_object_mut())
172    {
173        for (_provider_name, provider_cfg) in providers.iter_mut() {
174            let Some(obj) = provider_cfg.as_object_mut() else {
175                continue;
176            };
177            obj.remove("api_key_encrypted");
178        }
179    }
180
181    if let Some(provider_instances) = patch_obj
182        .get_mut("provider_instances")
183        .and_then(|v| v.as_object_mut())
184    {
185        for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
186            let Some(obj) = instance_cfg.as_object_mut() else {
187                continue;
188            };
189            obj.remove("api_key_encrypted");
190        }
191    }
192
193    // Never allow clients to set encrypted secret material directly.
194    //
195    // Canonical MCP format:
196    //   "mcpServers": { "<id>": { env_encrypted, headers[*].value_encrypted, ... } }
197    if let Some(mcp_servers) = patch_obj
198        .get_mut("mcpServers")
199        .and_then(|v| v.as_object_mut())
200    {
201        for (_id, server) in mcp_servers.iter_mut() {
202            let Some(server_obj) = server.as_object_mut() else {
203                continue;
204            };
205            server_obj.remove("env_encrypted");
206            if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
207                for header in headers.iter_mut() {
208                    let Some(header_obj) = header.as_object_mut() else {
209                        continue;
210                    };
211                    header_obj.remove("value_encrypted");
212                }
213            }
214        }
215    }
216
217    // Legacy MCP shape:
218    //   "mcp": { "servers": [ { transport: { env_encrypted / headers[*].value_encrypted } } ] }
219    if let Some(servers) = patch_obj
220        .get_mut("mcp")
221        .and_then(|m| m.get_mut("servers"))
222        .and_then(|v| v.as_array_mut())
223    {
224        for server in servers.iter_mut() {
225            let Some(server_obj) = server.as_object_mut() else {
226                continue;
227            };
228            let Some(transport) = server_obj
229                .get_mut("transport")
230                .and_then(|v| v.as_object_mut())
231            else {
232                continue;
233            };
234
235            match transport.get("type").and_then(|v| v.as_str()) {
236                Some("stdio") => {
237                    transport.remove("env_encrypted");
238                }
239                Some("sse") => {
240                    if let Some(headers) =
241                        transport.get_mut("headers").and_then(|v| v.as_array_mut())
242                    {
243                        for header in headers.iter_mut() {
244                            let Some(header_obj) = header.as_object_mut() else {
245                                continue;
246                            };
247                            header_obj.remove("value_encrypted");
248                        }
249                    }
250                }
251                _ => {}
252            }
253        }
254    }
255}
256
257/// Replace masked API key placeholders in a patch with the current config's plain keys.
258///
259/// The UI sends masked values (e.g. `****...****`) to indicate "do not change this key".
260/// This function resolves those back to the existing plain-text key from the live config.
261pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
262    if let Some(patch_providers) = patch_obj
263        .get_mut("providers")
264        .and_then(|v| v.as_object_mut())
265    {
266        for (provider_name, provider_patch) in patch_providers.iter_mut() {
267            let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
268                continue;
269            };
270
271            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
272                continue;
273            };
274            if !is_masked_api_key(api_key) {
275                continue;
276            }
277
278            let existing_plain = match provider_name.as_str() {
279                "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
280                "anthropic" => current
281                    .providers
282                    .anthropic
283                    .as_ref()
284                    .map(|c| c.api_key.clone()),
285                "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
286                "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
287                _ => None,
288            };
289
290            if let Some(existing_plain) = existing_plain {
291                if !existing_plain.trim().is_empty() {
292                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
293                } else {
294                    patch_cfg_obj.remove("api_key");
295                }
296            } else {
297                patch_cfg_obj.remove("api_key");
298            }
299        }
300    }
301
302    if let Some(patch_instances) = patch_obj
303        .get_mut("provider_instances")
304        .and_then(|v| v.as_object_mut())
305    {
306        for (instance_id, instance_patch) in patch_instances.iter_mut() {
307            let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
308                continue;
309            };
310
311            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
312                continue;
313            };
314            if !is_masked_api_key(api_key) {
315                continue;
316            }
317
318            let existing_plain = current
319                .provider_instances
320                .get(instance_id)
321                .map(|instance| instance.api_key.clone());
322
323            if let Some(existing_plain) = existing_plain {
324                if !existing_plain.trim().is_empty() {
325                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
326                } else {
327                    patch_cfg_obj.remove("api_key");
328                }
329            } else {
330                patch_cfg_obj.remove("api_key");
331            }
332        }
333    }
334}
335
336/// Deep merge `src` into `dst`, recursively combining objects and replacing leaf values.
337pub fn deep_merge_json(dst: &mut Value, src: Value) {
338    match (dst, src) {
339        (Value::Object(dst_map), Value::Object(src_map)) => {
340            for (key, value) in src_map {
341                match dst_map.get_mut(&key) {
342                    Some(existing) => deep_merge_json(existing, value),
343                    None => {
344                        dst_map.insert(key, value);
345                    }
346                }
347            }
348        }
349        (dst_slot, src_value) => {
350            *dst_slot = src_value;
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use serde_json::json;
359
360    #[test]
361    fn domains_for_root_patch_detects_proxy_and_provider() {
362        let patch = json!({
363            "provider": "openai",
364            "http_proxy": "http://proxy:8080",
365            "setup": { "completed": false },
366            "mcpServers": {}
367        });
368
369        let domains = domains_for_root_patch(patch.as_object().unwrap());
370        assert!(domains.provider);
371        assert!(domains.proxy);
372        assert!(domains.setup);
373        assert!(domains.mcp);
374    }
375
376    #[test]
377    fn domains_for_root_patch_detects_provider_instances() {
378        let patch = json!({
379            "provider_instances": {
380                "openai-work": { "provider_type": "openai" }
381            },
382            "default_provider_instance": "openai-work",
383            "defaults": {
384                "chat": { "provider": "openai-work", "model": "gpt-4o" }
385            },
386            "features": {
387                "provider_model_ref": true
388            }
389        });
390
391        let domains = domains_for_root_patch(patch.as_object().unwrap());
392        assert!(domains.provider);
393    }
394
395    #[test]
396    fn provider_api_key_intents_ignores_masked_placeholders() {
397        let patch = json!({
398            "providers": {
399                "openai": { "api_key": "****...****" },
400                "gemini": { "api_key": "sk-real" }
401            },
402            "provider_instances": {
403                "work-openai": { "api_key": "****...****" },
404                "personal-openai": { "api_key": "sk-live" }
405            }
406        });
407        let intents = provider_api_key_intents(patch.as_object().unwrap());
408        assert!(intents.providers.contains("gemini"));
409        assert!(!intents.providers.contains("openai"));
410        assert!(intents.provider_instances.contains("personal-openai"));
411        assert!(!intents.provider_instances.contains("work-openai"));
412    }
413}