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 notification-channel secrets directly.
201    if let Some(notifications) = patch_obj
202        .get_mut("notifications")
203        .and_then(|v| v.as_object_mut())
204    {
205        if let Some(ntfy) = notifications
206            .get_mut("ntfy")
207            .and_then(|v| v.as_object_mut())
208        {
209            ntfy.remove("token_encrypted");
210        }
211        if let Some(bark) = notifications
212            .get_mut("bark")
213            .and_then(|v| v.as_object_mut())
214        {
215            bark.remove("device_key_encrypted");
216        }
217    }
218
219    // Never allow clients to set encrypted secret material directly.
220    //
221    // Canonical MCP format:
222    //   "mcpServers": { "<id>": { env_encrypted, headers[*].value_encrypted, ... } }
223    if let Some(mcp_servers) = patch_obj
224        .get_mut("mcpServers")
225        .and_then(|v| v.as_object_mut())
226    {
227        for (_id, server) in mcp_servers.iter_mut() {
228            let Some(server_obj) = server.as_object_mut() else {
229                continue;
230            };
231            server_obj.remove("env_encrypted");
232            if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
233                for header in headers.iter_mut() {
234                    let Some(header_obj) = header.as_object_mut() else {
235                        continue;
236                    };
237                    header_obj.remove("value_encrypted");
238                }
239            }
240        }
241    }
242
243    // Legacy MCP shape:
244    //   "mcp": { "servers": [ { transport: { env_encrypted / headers[*].value_encrypted } } ] }
245    if let Some(servers) = patch_obj
246        .get_mut("mcp")
247        .and_then(|m| m.get_mut("servers"))
248        .and_then(|v| v.as_array_mut())
249    {
250        for server in servers.iter_mut() {
251            let Some(server_obj) = server.as_object_mut() else {
252                continue;
253            };
254            let Some(transport) = server_obj
255                .get_mut("transport")
256                .and_then(|v| v.as_object_mut())
257            else {
258                continue;
259            };
260
261            match transport.get("type").and_then(|v| v.as_str()) {
262                Some("stdio") => {
263                    transport.remove("env_encrypted");
264                }
265                Some("sse") => {
266                    if let Some(headers) =
267                        transport.get_mut("headers").and_then(|v| v.as_array_mut())
268                    {
269                        for header in headers.iter_mut() {
270                            let Some(header_obj) = header.as_object_mut() else {
271                                continue;
272                            };
273                            header_obj.remove("value_encrypted");
274                        }
275                    }
276                }
277                _ => {}
278            }
279        }
280    }
281}
282
283/// Replace masked API key placeholders in a patch with the current config's plain keys.
284///
285/// The UI sends masked values (e.g. `****...****`) to indicate "do not change this key".
286/// This function resolves those back to the existing plain-text key from the live config.
287pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
288    if let Some(patch_providers) = patch_obj
289        .get_mut("providers")
290        .and_then(|v| v.as_object_mut())
291    {
292        for (provider_name, provider_patch) in patch_providers.iter_mut() {
293            let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
294                continue;
295            };
296
297            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
298                continue;
299            };
300            if !is_masked_api_key(api_key) {
301                continue;
302            }
303
304            let existing_plain = match provider_name.as_str() {
305                "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
306                "anthropic" => current
307                    .providers
308                    .anthropic
309                    .as_ref()
310                    .map(|c| c.api_key.clone()),
311                "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
312                "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
313                _ => None,
314            };
315
316            if let Some(existing_plain) = existing_plain {
317                if !existing_plain.trim().is_empty() {
318                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
319                } else {
320                    patch_cfg_obj.remove("api_key");
321                }
322            } else {
323                patch_cfg_obj.remove("api_key");
324            }
325        }
326    }
327
328    if let Some(patch_instances) = patch_obj
329        .get_mut("provider_instances")
330        .and_then(|v| v.as_object_mut())
331    {
332        for (instance_id, instance_patch) in patch_instances.iter_mut() {
333            let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
334                continue;
335            };
336
337            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
338                continue;
339            };
340            if !is_masked_api_key(api_key) {
341                continue;
342            }
343
344            let existing_plain = current
345                .provider_instances
346                .get(instance_id)
347                .map(|instance| instance.api_key.clone());
348
349            if let Some(existing_plain) = existing_plain {
350                if !existing_plain.trim().is_empty() {
351                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
352                } else {
353                    patch_cfg_obj.remove("api_key");
354                }
355            } else {
356                patch_cfg_obj.remove("api_key");
357            }
358        }
359    }
360}
361
362/// Replace masked notification-channel secret placeholders (ntfy `token`, Bark
363/// `device_key`) in a patch with the current config's plaintext values.
364///
365/// Mirrors [`preserve_masked_provider_api_keys`]: the UI sends the masked
366/// placeholder to mean "do not change this secret"; this resolves that back to
367/// the live plaintext so the merge doesn't wipe it. A masked value with no
368/// existing plaintext (nothing configured yet) is dropped from the patch
369/// entirely, same as an unset key.
370pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
371    let Some(notifications) = patch_obj
372        .get_mut("notifications")
373        .and_then(|v| v.as_object_mut())
374    else {
375        return;
376    };
377
378    if let Some(ntfy) = notifications
379        .get_mut("ntfy")
380        .and_then(|v| v.as_object_mut())
381    {
382        preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
383    }
384
385    if let Some(bark) = notifications
386        .get_mut("bark")
387        .and_then(|v| v.as_object_mut())
388    {
389        preserve_masked_secret_field(
390            bark,
391            "device_key",
392            current.notifications.bark.device_key.as_deref(),
393        );
394    }
395}
396
397/// Resolve a single masked secret field in place: replace a masked placeholder
398/// with `existing_plain`, or drop the field if nothing is configured yet.
399/// A non-masked value (a genuine new secret, or an explicit empty-string
400/// clear) is left untouched.
401fn preserve_masked_secret_field(
402    obj: &mut Map<String, Value>,
403    field: &str,
404    existing_plain: Option<&str>,
405) {
406    let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
407        return;
408    };
409    if !is_masked_api_key(value) {
410        return;
411    }
412
413    match existing_plain {
414        Some(plain) if !plain.trim().is_empty() => {
415            obj.insert(field.to_string(), Value::String(plain.to_string()));
416        }
417        _ => {
418            obj.remove(field);
419        }
420    }
421}
422
423/// Deep merge `src` into `dst`, recursively combining objects and replacing leaf values.
424pub fn deep_merge_json(dst: &mut Value, src: Value) {
425    match (dst, src) {
426        (Value::Object(dst_map), Value::Object(src_map)) => {
427            for (key, value) in src_map {
428                match dst_map.get_mut(&key) {
429                    Some(existing) => deep_merge_json(existing, value),
430                    None => {
431                        dst_map.insert(key, value);
432                    }
433                }
434            }
435        }
436        (dst_slot, src_value) => {
437            *dst_slot = src_value;
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use serde_json::json;
446
447    #[test]
448    fn domains_for_root_patch_detects_proxy_and_provider() {
449        let patch = json!({
450            "provider": "openai",
451            "http_proxy": "http://proxy:8080",
452            "setup": { "completed": false },
453            "mcpServers": {}
454        });
455
456        let domains = domains_for_root_patch(patch.as_object().unwrap());
457        assert!(domains.provider);
458        assert!(domains.proxy);
459        assert!(domains.setup);
460        assert!(domains.mcp);
461    }
462
463    #[test]
464    fn domains_for_root_patch_detects_provider_instances() {
465        let patch = json!({
466            "provider_instances": {
467                "openai-work": { "provider_type": "openai" }
468            },
469            "default_provider_instance": "openai-work",
470            "defaults": {
471                "chat": { "provider": "openai-work", "model": "gpt-4o" }
472            },
473            "features": {
474                "provider_model_ref": true
475            }
476        });
477
478        let domains = domains_for_root_patch(patch.as_object().unwrap());
479        assert!(domains.provider);
480    }
481
482    #[test]
483    fn provider_api_key_intents_ignores_masked_placeholders() {
484        let patch = json!({
485            "providers": {
486                "openai": { "api_key": "****...****" },
487                "gemini": { "api_key": "sk-real" }
488            },
489            "provider_instances": {
490                "work-openai": { "api_key": "****...****" },
491                "personal-openai": { "api_key": "sk-live" }
492            }
493        });
494        let intents = provider_api_key_intents(patch.as_object().unwrap());
495        assert!(intents.providers.contains("gemini"));
496        assert!(!intents.providers.contains("openai"));
497        assert!(intents.provider_instances.contains("personal-openai"));
498        assert!(!intents.provider_instances.contains("work-openai"));
499    }
500
501    #[test]
502    fn is_masked_api_key_requires_placeholder_only_values() {
503        // The redaction placeholder and all-asterisk/dot variants are masked.
504        assert!(is_masked_api_key("****...****"));
505        assert!(is_masked_api_key("********"));
506        assert!(is_masked_api_key("  ****...****  "));
507
508        // Empty is a "clear" signal, not a mask.
509        assert!(!is_masked_api_key(""));
510        assert!(!is_masked_api_key("   "));
511
512        // A placeholder with a real key pasted after it must NOT be treated as
513        // masked — that silently discards the user's new token (#430).
514        assert!(!is_masked_api_key("****...****sk-newkey123"));
515        assert!(!is_masked_api_key("sk-newkey123****...****"));
516
517        // Real keys containing dots or asterisks among other characters are keys.
518        assert!(!is_masked_api_key("id.secret...suffix"));
519        assert!(!is_masked_api_key("sk-live-abc"));
520    }
521
522    #[test]
523    fn sanitize_root_patch_strips_notification_encrypted_fields() {
524        let mut patch = json!({
525            "notifications": {
526                "ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
527                "bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
528            }
529        });
530        let obj = patch.as_object_mut().unwrap();
531        sanitize_root_patch(obj);
532
533        assert!(!obj["notifications"]["ntfy"]
534            .as_object()
535            .unwrap()
536            .contains_key("token_encrypted"));
537        assert!(!obj["notifications"]["bark"]
538            .as_object()
539            .unwrap()
540            .contains_key("device_key_encrypted"));
541        // Plaintext fields the client legitimately sent are untouched.
542        assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
543        assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
544    }
545
546    #[test]
547    fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
548        let mut current = Config::default();
549        current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
550        current.notifications.bark.device_key = Some("existing-bark-key".to_string());
551
552        let mut patch: Map<String, Value> = serde_json::from_str(
553            r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
554        )
555        .unwrap();
556
557        preserve_masked_notification_secrets(&mut patch, &current);
558
559        assert_eq!(
560            patch["notifications"]["ntfy"]["token"],
561            "existing-ntfy-token"
562        );
563        assert_eq!(
564            patch["notifications"]["bark"]["device_key"],
565            "existing-bark-key"
566        );
567    }
568
569    #[test]
570    fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
571        let current = Config::default();
572        let mut patch: Map<String, Value> =
573            serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
574
575        preserve_masked_notification_secrets(&mut patch, &current);
576
577        assert!(!patch["notifications"]["ntfy"]
578            .as_object()
579            .unwrap()
580            .contains_key("token"));
581    }
582
583    #[test]
584    fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
585        let current = Config::default();
586        let mut patch: Map<String, Value> =
587            serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
588                .unwrap();
589
590        preserve_masked_notification_secrets(&mut patch, &current);
591
592        assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
593    }
594}