Skip to main content

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