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_config::patch::ProviderApiKeyIntents;
24use bamboo_llm::Config;
25
26// Re-export pure domain logic from the config crate so server consumers
27// can import through `config_manager`.
28pub use bamboo_config::patch::{
29    clear_connect_ciphertext_for_explicit_clears,
30    clear_notification_ciphertext_for_explicit_clears,
31    clear_provider_ciphertext_for_explicit_clears, connect_secret_intents, deep_merge_json,
32    domains_for_root_patch, effects_for_root_patch, is_masked_api_key, notification_secret_intents,
33    preserve_masked_connect_secrets, preserve_masked_notification_secrets,
34    preserve_masked_provider_api_keys, preserve_unpatched_notification_secrets,
35    preserve_unpatched_provider_secrets, provider_api_key_intents, sanitize_root_patch,
36    ConnectSecretIntents, DomainChanges, NotificationSecretIntents, PatchEffects, ReloadMode,
37};
38
39pub fn sync_provider_api_keys_encrypted_for_patch(
40    config: &mut Config,
41    intents: &ProviderApiKeyIntents,
42) -> Result<(), AppError> {
43    for name in intents.providers.iter() {
44        match name.as_str() {
45            "openai" => {
46                if let Some(openai) = config.providers_mut().openai.as_mut() {
47                    // Never encrypt/persist an env-sourced key — mirrors
48                    // refresh_provider_api_keys_encrypted's guard (#253). Without
49                    // it, an explicit `api_key: ""` clear of an env-sourced provider
50                    // (which preserve_env_sourced_provider_keys refills with the env
51                    // secret + api_key_from_env=true) would bake that plaintext
52                    // secret into config.json here. An explicit NEW key resets
53                    // api_key_from_env=false, so a real override still persists. #373.
54                    if !openai.api_key_from_env {
55                        let api_key = openai.api_key.trim();
56                        openai.api_key_encrypted = if api_key.is_empty() {
57                            None
58                        } else {
59                            Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
60                                AppError::InternalError(anyhow::anyhow!(
61                                    "Failed to encrypt OpenAI api_key: {e}"
62                                ))
63                            })?)
64                        };
65                    }
66                }
67            }
68            "anthropic" => {
69                if let Some(anthropic) = config.providers_mut().anthropic.as_mut() {
70                    // Skip env-sourced keys (see openai above). #373.
71                    if !anthropic.api_key_from_env {
72                        let api_key = anthropic.api_key.trim();
73                        anthropic.api_key_encrypted = if api_key.is_empty() {
74                            None
75                        } else {
76                            Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
77                                AppError::InternalError(anyhow::anyhow!(
78                                    "Failed to encrypt Anthropic api_key: {e}"
79                                ))
80                            })?)
81                        };
82                    }
83                }
84            }
85            "gemini" => {
86                if let Some(gemini) = config.providers_mut().gemini.as_mut() {
87                    // Skip env-sourced keys (see openai above). #373.
88                    if !gemini.api_key_from_env {
89                        let api_key = gemini.api_key.trim();
90                        gemini.api_key_encrypted = if api_key.is_empty() {
91                            None
92                        } else {
93                            Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
94                                AppError::InternalError(anyhow::anyhow!(
95                                    "Failed to encrypt Gemini api_key: {e}"
96                                ))
97                            })?)
98                        };
99                    }
100                }
101            }
102            "bodhi" => {
103                if let Some(bodhi) = config.providers_mut().bodhi.as_mut() {
104                    let api_key = bodhi.api_key.trim();
105                    bodhi.api_key_encrypted = if api_key.is_empty() {
106                        None
107                    } else {
108                        Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
109                            AppError::InternalError(anyhow::anyhow!(
110                                "Failed to encrypt Bodhi api_key: {e}"
111                            ))
112                        })?)
113                    };
114                }
115            }
116            _ => {}
117        }
118    }
119
120    for instance_id in intents.provider_instances.iter() {
121        if let Some(instance) = config.provider_instances.get_mut(instance_id) {
122            let api_key = instance.api_key.trim();
123            instance.api_key_encrypted = if api_key.is_empty() {
124                None
125            } else {
126                Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
127                    AppError::InternalError(anyhow::anyhow!(
128                        "Failed to encrypt provider instance api_key for '{instance_id}': {e}"
129                    ))
130                })?)
131            };
132        }
133    }
134
135    Ok(())
136}
137
138pub fn assert_json_object(value: Value) -> Result<Map<String, Value>, AppError> {
139    match value {
140        Value::Object(map) => Ok(map),
141        _ => Err(AppError::BadRequest(
142            "config.json must be a JSON object".to_string(),
143        )),
144    }
145}
146
147/// Legacy full-config clients may echo the secret-free Core proxy projection,
148/// but they may not mutate it without the owned Core section revision.
149///
150/// Dropping only an exact lock-time echo preserves bounded compatibility while
151/// ensuring proxy URLs and the server-managed credential reference cannot
152/// bypass the typed Core/proxy-auth APIs.
153pub fn remove_unchanged_core_proxy_echo(
154    current: &Config,
155    patch_obj: &mut Map<String, Value>,
156) -> Result<(), AppError> {
157    if ["proxy_auth", "proxy_auth_encrypted"]
158        .iter()
159        .any(|field| patch_obj.contains_key(*field))
160    {
161        return Err(core_proxy_patch_error());
162    }
163    let current_value = current.to_compatibility_value()?;
164    for field in ["http_proxy", "https_proxy", "proxy_auth_credential_ref"] {
165        let Some(incoming) = patch_obj.get(field) else {
166            continue;
167        };
168        if current_value.get(field) != Some(incoming) {
169            return Err(core_proxy_patch_error());
170        }
171        patch_obj.remove(field);
172    }
173    Ok(())
174}
175
176fn core_proxy_patch_error() -> AppError {
177    AppError::BadRequest(
178        "proxy configuration must be changed through the dedicated revisioned Core and proxy-auth APIs"
179            .to_string(),
180    )
181}
182
183pub fn build_merged_config(
184    current: &Config,
185    patch_obj: Map<String, Value>,
186) -> Result<Config, AppError> {
187    // Captured before the merge consumes the patch: which providers/instances
188    // this patch explicitly sets or clears — those must NOT get their dropped
189    // key carried forward below (#516).
190    let api_key_intents = provider_api_key_intents(&patch_obj);
191    // Same capture for the other secret domains that flow through this merge
192    // (#521 — ntfy `token` / Bark `device_key` / connect `token` and Feishu
193    // `app_secret`): must be read before the patch is consumed below.
194    let notification_intents = notification_secret_intents(&patch_obj);
195    let connect_intents = connect_secret_intents(&patch_obj);
196
197    let mut merged = current
198        .to_compatibility_value()
199        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to serialize config: {e}")))?;
200
201    deep_merge_json(&mut merged, Value::Object(patch_obj));
202
203    let mut new_config: Config = serde_json::from_value(merged)
204        .map_err(|e| AppError::BadRequest(format!("Invalid configuration JSON: {e}")))?;
205    // An explicit `api_key: ""` clear must drop the round-tripped ciphertext
206    // BEFORE hydration — otherwise hydration refills the plaintext from it and
207    // the subsequent sync/save re-encrypts, silently undoing the clear (#516).
208    clear_provider_ciphertext_for_explicit_clears(&mut new_config, &api_key_intents);
209    // Same treatment for notification/connect secrets (#521) — same rationale,
210    // same ordering requirement (before hydration below).
211    clear_notification_ciphertext_for_explicit_clears(&mut new_config, &notification_intents);
212    clear_connect_ciphertext_for_explicit_clears(&mut new_config, &connect_intents);
213    new_config.hydrate_proxy_auth_from_encrypted();
214    // Proxy auth is credential-store backed and intentionally omitted from the
215    // compatibility JSON round-trip. Root PATCH sanitization forbids changing
216    // both the secret and its reference, so preserve the already-hydrated live
217    // value just like the CLI dot-path setter does.
218    if new_config.proxy_auth_credential_ref == current.proxy_auth_credential_ref {
219        new_config.proxy_auth = current.proxy_auth.clone();
220    }
221    new_config.hydrate_provider_api_keys_from_encrypted();
222    new_config.hydrate_provider_instance_api_keys_from_encrypted();
223    new_config.hydrate_mcp_secrets_from_encrypted();
224    new_config.hydrate_env_vars_from_encrypted();
225    new_config.hydrate_notifications_from_encrypted();
226    preserve_unpatched_notification_secrets(&mut new_config, current, &notification_intents);
227    new_config.hydrate_connect_platform_tokens_from_encrypted();
228    // The serde round-trip above drops every provider's `#[serde(skip_serializing)]`
229    // `api_key`; hydration only restores ciphertext-backed keys, so an env-sourced
230    // key (no ciphertext, #253) would be silently blanked by any settings PATCH.
231    // Copy env-sourced keys back from the live `current` config. #373.
232    new_config.preserve_env_sourced_provider_keys(current);
233    // Same round-trip hazard for any OTHER plaintext-only key (ciphertext still
234    // `None` in the live config — e.g. a provider instance freshly created via
235    // the instance CRUD endpoints): hydration has nothing to decrypt and the
236    // key would vanish from config.json on the next persist. Carry unpatched
237    // keys forward from the live config. #515/#516.
238    preserve_unpatched_provider_secrets(&mut new_config, current, &api_key_intents);
239    // Explicit instance preserve path for #633: carry untouched instance
240    // plaintext keys (including instances created before first persist) from
241    // current to merged when the merge consumed the merge-time plaintext.
242    new_config.preserve_provider_instance_plaintext_keys(current, &api_key_intents);
243    new_config.normalize_tool_settings();
244    new_config.normalize_skill_settings();
245    new_config.normalize_plugin_trust_settings();
246
247    Ok(new_config)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use bamboo_config::{credential_ref, OpenAIConfig, ProxyAuth};
254
255    #[test]
256    fn unrelated_root_patch_preserves_store_hydrated_proxy_auth() {
257        let mut current = Config::default();
258        current.proxy_auth_credential_ref =
259            Some(credential_ref("proxy", "default", "auth").unwrap());
260        current.proxy_auth = Some(ProxyAuth {
261            username: "proxy-user".to_string(),
262            password: "proxy-password".to_string(),
263        });
264        let patch: Map<String, Value> =
265            serde_json::from_str(r#"{"http_proxy":"http://proxy.example:8080"}"#).unwrap();
266
267        let merged = build_merged_config(&current, patch).expect("merge");
268
269        assert_eq!(
270            merged.proxy_auth_credential_ref,
271            current.proxy_auth_credential_ref
272        );
273        let auth = merged
274            .proxy_auth
275            .as_ref()
276            .expect("live proxy auth must survive");
277        assert_eq!(auth.username, "proxy-user");
278        assert_eq!(auth.password, "proxy-password");
279    }
280
281    fn env_sourced_openai_config() -> Config {
282        let mut config = Config::default();
283        config.providers_mut().openai = Some(OpenAIConfig {
284            api_key: "sk-env-secret".to_string(),
285            api_key_from_env: true,
286            ..Default::default()
287        });
288        config
289    }
290
291    // #373: an explicit `api_key: ""` clear of an env-sourced provider must NOT
292    // bake the env secret into config.json. build_merged_config restores the live
293    // env key (api_key_from_env=true), and the from_env guard in
294    // sync_provider_api_keys_encrypted_for_patch must then skip encrypting it.
295    #[test]
296    fn clearing_env_sourced_key_does_not_persist_the_secret() {
297        let current = env_sourced_openai_config();
298        let patch: Map<String, Value> =
299            serde_json::from_str(r#"{"providers":{"openai":{"api_key":""}}}"#).unwrap();
300        let intents = provider_api_key_intents(&patch);
301        assert!(
302            intents.providers.contains("openai"),
303            "empty string is a clear intent"
304        );
305
306        let mut merged = build_merged_config(&current, patch).expect("merge");
307        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
308
309        let openai = merged.providers().openai.as_ref().unwrap();
310        assert!(
311            openai.api_key_encrypted.is_none(),
312            "env secret must NOT be encrypted to disk on a clear"
313        );
314        assert!(openai.api_key_from_env, "still flagged env-sourced");
315        assert_eq!(openai.api_key, "sk-env-secret", "live env key preserved");
316    }
317
318    // A genuine NEW key for an env-sourced provider resets api_key_from_env=false
319    // and IS persisted (the explicit override wins).
320    #[test]
321    fn explicit_new_key_overrides_env_and_persists() {
322        let current = env_sourced_openai_config();
323        let patch: Map<String, Value> =
324            serde_json::from_str(r#"{"providers":{"openai":{"api_key":"sk-brand-new"}}}"#).unwrap();
325        let intents = provider_api_key_intents(&patch);
326
327        let mut merged = build_merged_config(&current, patch).expect("merge");
328        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
329
330        let openai = merged.providers().openai.as_ref().unwrap();
331        assert_eq!(openai.api_key, "sk-brand-new", "explicit override wins");
332        assert!(!openai.api_key_from_env, "override clears the env flag");
333        assert!(
334            openai.api_key_encrypted.is_some(),
335            "a real override is encrypted/persisted"
336        );
337    }
338
339    // A PATCH that doesn't touch api_key must not drop the env-sourced key.
340    #[test]
341    fn unrelated_patch_preserves_env_key() {
342        let current = env_sourced_openai_config();
343        let patch: Map<String, Value> =
344            serde_json::from_str(r#"{"providers":{"openai":{"model":"gpt-x"}}}"#).unwrap();
345        let intents = provider_api_key_intents(&patch);
346        assert!(
347            !intents.providers.contains("openai"),
348            "no api_key in patch → no intent"
349        );
350
351        let mut merged = build_merged_config(&current, patch).expect("merge");
352        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
353
354        let openai = merged.providers().openai.as_ref().unwrap();
355        assert_eq!(
356            openai.api_key, "sk-env-secret",
357            "env key preserved across unrelated patch"
358        );
359        assert!(openai.api_key_from_env);
360        assert!(openai.api_key_encrypted.is_none(), "still not persisted");
361    }
362
363    /// The live in-memory state right after `POST /provider-instances`:
364    /// plaintext key, ciphertext still `None` (ciphertext is only ever computed
365    /// on `save_to_dir`'s save-time clone).
366    fn config_with_plaintext_only_instance(api_key: &str) -> Config {
367        let mut config = Config::default();
368        let instance: bamboo_config::ProviderInstanceConfig =
369            serde_json::from_value(serde_json::json!({
370                "provider_type": "openai",
371                "api_key": api_key,
372            }))
373            .expect("valid instance");
374        config
375            .provider_instances
376            .insert("uuid-1".to_string(), instance);
377        config
378    }
379
380    // #516 regression: the lotus instance-mode 保存配置 sends a defaults/features
381    // patch that never mentions the instance. The merge round-trip drops the
382    // `skip_serializing` plaintext, hydration has no ciphertext to restore, and
383    // the freshly-created instance's key was silently wiped from config.json
384    // (config.json.bak kept the previous good copy).
385    #[test]
386    fn unrelated_patch_preserves_plaintext_only_instance_key() {
387        let current = config_with_plaintext_only_instance("sk-instance-live");
388
389        let patch: Map<String, Value> =
390            serde_json::from_str(r#"{"features":{"provider_model_ref":true}}"#).unwrap();
391        let intents = provider_api_key_intents(&patch);
392        assert!(intents.provider_instances.is_empty());
393
394        let mut merged = build_merged_config(&current, patch).expect("merge");
395        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
396
397        let instance = merged.provider_instances.get("uuid-1").expect("instance");
398        assert_eq!(
399            instance.api_key, "sk-instance-live",
400            "an unrelated settings PATCH must not lose the instance key (#516)"
401        );
402    }
403
404    #[test]
405    fn unrelated_patch_preserves_fresh_instance_plaintext_key() {
406        let current = config_with_plaintext_only_instance("sk-instance-fresh");
407
408        let patch: Map<String, Value> = serde_json::from_str(
409            r#"{"defaults":{"chat":{"provider":"openai","model":"gpt-4o-mini","temperature":1}}}"#,
410        )
411        .unwrap();
412        let intents = provider_api_key_intents(&patch);
413        assert!(intents.provider_instances.is_empty());
414
415        let mut merged = build_merged_config(&current, patch).expect("merge");
416        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
417
418        let instance = merged.provider_instances.get("uuid-1").expect("instance");
419        assert_eq!(instance.api_key, "sk-instance-fresh");
420    }
421
422    #[test]
423    fn explicit_instance_key_in_patch_wins_over_preserved_key() {
424        let current = config_with_plaintext_only_instance("sk-instance-live");
425        let patch: Map<String, Value> = serde_json::from_str(
426            r#"{"provider_instances":{"uuid-1":{"api_key":"sk-instance-updated"}}}"#,
427        )
428        .unwrap();
429        let intents = provider_api_key_intents(&patch);
430        assert!(intents.provider_instances.contains("uuid-1"));
431
432        let mut merged = build_merged_config(&current, patch).expect("merge");
433        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
434
435        let instance = merged.provider_instances.get("uuid-1").expect("instance");
436        assert_eq!(instance.api_key, "sk-instance-updated");
437        assert!(
438            instance
439                .api_key_encrypted
440                .as_deref()
441                .is_some_and(|cipher| !cipher.is_empty()),
442            "explicit instance key must be encrypted after sync"
443        );
444    }
445
446    // The carry-forward must not resurrect a key the patch explicitly cleared.
447    #[test]
448    fn explicit_instance_key_clear_still_clears() {
449        let current = config_with_plaintext_only_instance("sk-old");
450
451        let patch: Map<String, Value> =
452            serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
453        let intents = provider_api_key_intents(&patch);
454        assert!(
455            intents.provider_instances.contains("uuid-1"),
456            "empty string is a clear intent"
457        );
458
459        let mut merged = build_merged_config(&current, patch).expect("merge");
460        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
461
462        let instance = merged.provider_instances.get("uuid-1").expect("instance");
463        assert!(instance.api_key.is_empty(), "explicit clear must win");
464        assert!(instance.api_key_encrypted.is_none());
465    }
466
467    // An explicit clear must also win when the live config holds ciphertext in
468    // memory — the normal state now that update_config keeps ciphertext in sync
469    // (#516). Without the pre-hydration ciphertext drop, hydration would refill
470    // the plaintext from the round-tripped ciphertext and sync would re-encrypt
471    // it, silently undoing the clear.
472    #[test]
473    fn explicit_instance_key_clear_wins_over_in_memory_ciphertext() {
474        let mut current = config_with_plaintext_only_instance("sk-old");
475        current.refresh_encrypted_secrets().expect("refresh");
476        assert!(
477            current.provider_instances["uuid-1"]
478                .api_key_encrypted
479                .is_some(),
480            "precondition: live config holds ciphertext"
481        );
482
483        let patch: Map<String, Value> =
484            serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
485        let intents = provider_api_key_intents(&patch);
486
487        let mut merged = build_merged_config(&current, patch).expect("merge");
488        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
489
490        let instance = merged.provider_instances.get("uuid-1").expect("instance");
491        assert!(instance.api_key.is_empty(), "explicit clear must win");
492        assert!(
493            instance.api_key_encrypted.is_none(),
494            "ciphertext must be cleared too"
495        );
496    }
497
498    // #515: an unrelated settings-save PATCH must also preserve an instance
499    // whose live config already holds ciphertext in memory (the normal state
500    // now that update_config keeps ciphertext in sync) — both the plaintext
501    // AND the exact stored ciphertext must survive the round trip.
502    #[test]
503    fn unrelated_patch_preserves_provider_instance_ciphertext() {
504        let mut current = config_with_plaintext_only_instance("sk-instance-secret");
505        current.refresh_encrypted_secrets().expect("refresh");
506        let prev_ciphertext = current.provider_instances["uuid-1"]
507            .api_key_encrypted
508            .clone()
509            .expect("current should have ciphertext");
510
511        let patch: Map<String, Value> =
512            serde_json::from_str(r#"{"http_proxy":"http://example.invalid:8080"}"#).unwrap();
513        let intents = provider_api_key_intents(&patch);
514        assert!(intents.provider_instances.is_empty());
515
516        let mut merged = build_merged_config(&current, patch).expect("merge");
517        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
518
519        let instance = &merged.provider_instances["uuid-1"];
520        assert_eq!(
521            instance.api_key, "sk-instance-secret",
522            "plaintext must survive an unrelated save"
523        );
524        assert_eq!(
525            instance.api_key_encrypted.as_deref(),
526            Some(prev_ciphertext.as_str()),
527            "ciphertext must survive an unrelated save"
528        );
529    }
530
531    // ── #521: full-pipeline coverage for notification secrets ──────────
532    //
533    // Mirrors set_bamboo_config's actual call order: preserve_masked_* first
534    // (mutating the patch against `current`), THEN build_merged_config, THEN
535    // the post-merge re-encrypt (`refresh_notifications_encrypted`, run in
536    // production by `Config::refresh_encrypted_secrets` inside
537    // `AppState::update_config`).
538
539    fn config_with_notification_secrets(ntfy_token: &str, bark_key: &str) -> Config {
540        let mut config = Config::default();
541        config.notifications.ntfy.token = Some(ntfy_token.to_string());
542        config.notifications.bark.device_key = Some(bark_key.to_string());
543        config.refresh_encrypted_secrets().expect("refresh");
544        config
545    }
546
547    fn merge_notifications_patch(current: &Config, patch_json: &str) -> Config {
548        let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
549        preserve_masked_notification_secrets(&mut patch_obj, current);
550        let mut merged = build_merged_config(current, patch_obj).expect("merge");
551        merged.refresh_encrypted_secrets().expect("refresh");
552        merged
553    }
554
555    #[test]
556    fn explicit_notification_secret_clear_wins_over_in_memory_ciphertext() {
557        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
558        assert!(current.notifications.ntfy.token_encrypted.is_some());
559        assert!(current.notifications.bark.device_key_encrypted.is_some());
560
561        let merged = merge_notifications_patch(
562            &current,
563            r#"{"notifications":{"ntfy":{"token":""},"bark":{"device_key":""}}}"#,
564        );
565
566        assert!(
567            merged
568                .notifications
569                .ntfy
570                .token
571                .as_deref()
572                .unwrap_or("")
573                .is_empty(),
574            "explicit clear must win"
575        );
576        assert!(
577            merged.notifications.ntfy.token_encrypted.is_none(),
578            "ciphertext must be cleared too (#521)"
579        );
580        assert!(merged
581            .notifications
582            .bark
583            .device_key
584            .as_deref()
585            .unwrap_or("")
586            .is_empty());
587        assert!(merged.notifications.bark.device_key_encrypted.is_none());
588    }
589
590    #[test]
591    fn unrelated_patch_preserves_notification_secrets() {
592        // Compatibility merging must carry store-hydrated plaintext forward
593        // even though it is intentionally absent from serialized JSON.
594        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
595
596        let merged =
597            merge_notifications_patch(&current, r#"{"http_proxy":"http://example.invalid:8080"}"#);
598
599        assert_eq!(
600            merged.notifications.ntfy.token.as_deref(),
601            Some("ntfy-secret"),
602            "an unrelated settings PATCH must not lose the ntfy token"
603        );
604        assert!(merged.notifications.ntfy.token_encrypted.is_some());
605        assert_eq!(
606            merged.notifications.bark.device_key.as_deref(),
607            Some("bark-secret"),
608            "an unrelated settings PATCH must not lose the Bark device key"
609        );
610        assert!(merged.notifications.bark.device_key_encrypted.is_some());
611    }
612
613    #[test]
614    fn masked_notification_secret_placeholder_preserves_value() {
615        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
616
617        let merged = merge_notifications_patch(
618            &current,
619            r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
620        );
621
622        assert_eq!(
623            merged.notifications.ntfy.token.as_deref(),
624            Some("ntfy-secret")
625        );
626        assert!(merged.notifications.ntfy.token_encrypted.is_some());
627        assert_eq!(
628            merged.notifications.bark.device_key.as_deref(),
629            Some("bark-secret")
630        );
631        assert!(merged.notifications.bark.device_key_encrypted.is_some());
632    }
633
634    #[test]
635    fn new_notification_secret_value_replaces_and_encrypts() {
636        let current = config_with_notification_secrets("ntfy-old", "bark-old");
637
638        let merged = merge_notifications_patch(
639            &current,
640            r#"{"notifications":{"ntfy":{"token":"ntfy-new"},"bark":{"device_key":"bark-new"}}}"#,
641        );
642
643        assert_eq!(merged.notifications.ntfy.token.as_deref(), Some("ntfy-new"));
644        assert!(merged.notifications.ntfy.token_encrypted.is_some());
645        assert_eq!(
646            merged.notifications.bark.device_key.as_deref(),
647            Some("bark-new")
648        );
649        assert!(merged.notifications.bark.device_key_encrypted.is_some());
650    }
651
652    // ── #521: full-pipeline coverage for connect platform secrets ──────
653
654    fn config_with_connect_platform(platform_type: &str, token: &str) -> Config {
655        let mut config = Config::default();
656        let platform: bamboo_config::ConnectPlatformConfig =
657            serde_json::from_value(serde_json::json!({
658                "type": platform_type,
659                "token": token,
660            }))
661            .expect("valid platform");
662        config.connect.platforms = vec![platform];
663        config.refresh_encrypted_secrets().expect("refresh");
664        config
665    }
666
667    fn merge_connect_patch(current: &Config, patch_json: &str) -> Config {
668        let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
669        preserve_masked_connect_secrets(&mut patch_obj, current);
670        let mut merged = build_merged_config(current, patch_obj).expect("merge");
671        merged.refresh_encrypted_secrets().expect("refresh");
672        merged
673    }
674
675    #[test]
676    fn explicit_connect_token_clear_wins_over_in_memory_ciphertext() {
677        let current = config_with_connect_platform("telegram", "tg-secret-token");
678        assert!(current.connect.platforms[0].token_encrypted.is_some());
679
680        let merged = merge_connect_patch(
681            &current,
682            r#"{"connect":{"platforms":[{"type":"telegram","token":""}]}}"#,
683        );
684
685        assert!(
686            merged.connect.platforms[0]
687                .token
688                .as_deref()
689                .unwrap_or("")
690                .is_empty(),
691            "explicit clear must win"
692        );
693        assert!(
694            merged.connect.platforms[0].token_encrypted.is_none(),
695            "ciphertext must be cleared too (#521)"
696        );
697    }
698
699    #[test]
700    fn explicit_connect_app_secret_clear_wins_over_in_memory_ciphertext() {
701        let mut current = Config::default();
702        let platform: bamboo_config::ConnectPlatformConfig =
703            serde_json::from_value(serde_json::json!({
704                "type": "feishu",
705                "app_id": "cli_x",
706                "app_secret": "feishu-secret",
707            }))
708            .expect("valid platform");
709        current.connect.platforms = vec![platform];
710        current.refresh_encrypted_secrets().expect("refresh");
711        assert!(current.connect.platforms[0].app_secret_encrypted.is_some());
712
713        let merged = merge_connect_patch(
714            &current,
715            r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":""}]}}"#,
716        );
717
718        assert!(
719            merged.connect.platforms[0]
720                .app_secret
721                .as_deref()
722                .unwrap_or("")
723                .is_empty(),
724            "explicit clear must win"
725        );
726        assert!(
727            merged.connect.platforms[0].app_secret_encrypted.is_none(),
728            "ciphertext must be cleared too (#521)"
729        );
730    }
731
732    #[test]
733    fn unrelated_patch_preserves_connect_token() {
734        // Same NOTE as `unrelated_patch_preserves_notification_secrets`: connect
735        // ciphertext is unconditionally recomputed by `refresh_encrypted_secrets`
736        // on every write, so only plaintext survival + ciphertext presence are
737        // asserted.
738        let current = config_with_connect_platform("telegram", "tg-secret-token");
739
740        let merged =
741            merge_connect_patch(&current, r#"{"http_proxy":"http://example.invalid:8080"}"#);
742
743        assert_eq!(
744            merged.connect.platforms[0].token.as_deref(),
745            Some("tg-secret-token"),
746            "an unrelated settings PATCH must not lose the connect platform token"
747        );
748        assert!(merged.connect.platforms[0].token_encrypted.is_some());
749    }
750
751    #[test]
752    fn masked_connect_token_placeholder_preserves_value() {
753        let current = config_with_connect_platform("telegram", "tg-secret-token");
754
755        let merged = merge_connect_patch(
756            &current,
757            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
758        );
759
760        assert_eq!(
761            merged.connect.platforms[0].token.as_deref(),
762            Some("tg-secret-token")
763        );
764        assert!(merged.connect.platforms[0].token_encrypted.is_some());
765    }
766
767    #[test]
768    fn new_connect_token_value_replaces_and_encrypts() {
769        let current = config_with_connect_platform("telegram", "tg-old-token");
770
771        let merged = merge_connect_patch(
772            &current,
773            r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-new-token"}]}}"#,
774        );
775
776        assert_eq!(
777            merged.connect.platforms[0].token.as_deref(),
778            Some("tg-new-token")
779        );
780        assert!(merged.connect.platforms[0].token_encrypted.is_some());
781    }
782
783    // ── #505: RFC7386-style null-delete through the FULL production pipeline ──
784    //
785    // These mirror the existing `""`-clear tests above (same helpers, same
786    // call order: preserve_masked_* → build_merged_config →
787    // sync_provider_api_keys_encrypted_for_patch / refresh_encrypted_secrets)
788    // but exercise a `null` clear instead, proving the new delete semantics
789    // compose correctly with the #516/#521 secret machinery end-to-end, not
790    // just at the `bamboo-config`-crate unit level.
791
792    #[test]
793    fn null_instance_api_key_clear_wins_over_in_memory_ciphertext() {
794        // Same scenario as `explicit_instance_key_clear_wins_over_in_memory_ciphertext`
795        // above, but the client sends `null` instead of `""`.
796        let mut current = config_with_plaintext_only_instance("sk-old");
797        current.refresh_encrypted_secrets().expect("refresh");
798        assert!(
799            current.provider_instances["uuid-1"]
800                .api_key_encrypted
801                .is_some(),
802            "precondition: live config holds ciphertext"
803        );
804
805        let patch: Map<String, Value> =
806            serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":null}}}"#).unwrap();
807        let intents = provider_api_key_intents(&patch);
808        assert!(
809            intents.provider_instances.contains("uuid-1"),
810            "null must register as a clear intent, same as \"\""
811        );
812
813        let mut merged = build_merged_config(&current, patch).expect("merge");
814        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
815
816        let instance = merged.provider_instances.get("uuid-1").expect("instance");
817        assert!(instance.api_key.is_empty(), "null clear must win");
818        assert!(
819            instance.api_key_encrypted.is_none(),
820            "ciphertext must be cleared too, not resurrected via hydration"
821        );
822    }
823
824    #[test]
825    fn null_deletes_a_whole_provider_instance_entry() {
826        // The other half of #505: deleting an entire map entry (not just
827        // clearing one field within it). Two instances exist; the patch
828        // null-deletes one by id and must leave the other untouched.
829        let mut current = config_with_plaintext_only_instance("sk-keep-me");
830        let second: bamboo_config::ProviderInstanceConfig =
831            serde_json::from_value(serde_json::json!({
832                "provider_type": "anthropic",
833                "label": "Delete Me",
834            }))
835            .expect("valid instance");
836        current
837            .provider_instances
838            .insert("uuid-2".to_string(), second);
839
840        let patch: Map<String, Value> =
841            serde_json::from_str(r#"{"provider_instances":{"uuid-2":null}}"#).unwrap();
842        let intents = provider_api_key_intents(&patch);
843
844        let mut merged = build_merged_config(&current, patch).expect("merge");
845        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
846
847        assert!(
848            !merged.provider_instances.contains_key("uuid-2"),
849            "the null-targeted instance must be gone"
850        );
851        assert_eq!(
852            merged
853                .provider_instances
854                .get("uuid-1")
855                .map(|i| &i.provider_type),
856            Some(&"openai".to_string()),
857            "the untouched sibling instance must survive"
858        );
859    }
860
861    #[test]
862    fn null_ntfy_token_clear_wins_over_in_memory_ciphertext() {
863        // Same scenario as `explicit_notification_secret_clear_wins_over_in_memory_ciphertext`
864        // above, but only ntfy is cleared, and via `null` rather than `""`.
865        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
866
867        let merged =
868            merge_notifications_patch(&current, r#"{"notifications":{"ntfy":{"token":null}}}"#);
869
870        assert!(
871            merged
872                .notifications
873                .ntfy
874                .token
875                .as_deref()
876                .unwrap_or("")
877                .is_empty(),
878            "null clear must win"
879        );
880        assert!(
881            merged.notifications.ntfy.token_encrypted.is_none(),
882            "ciphertext must be cleared too, not resurrected via hydration"
883        );
884        // Sibling secret domain (bark), untouched by the patch, must survive.
885        assert_eq!(
886            merged.notifications.bark.device_key.as_deref(),
887            Some("bark-secret")
888        );
889        assert!(merged.notifications.bark.device_key_encrypted.is_some());
890    }
891
892    #[test]
893    fn null_connect_token_clear_wins_over_in_memory_ciphertext() {
894        let current = config_with_connect_platform("telegram", "tg-old-token");
895
896        let merged = merge_connect_patch(
897            &current,
898            r#"{"connect":{"platforms":[{"type":"telegram","token":null}]}}"#,
899        );
900
901        assert!(
902            merged.connect.platforms[0]
903                .token
904                .as_deref()
905                .unwrap_or("")
906                .is_empty(),
907            "null clear must win"
908        );
909        assert!(
910            merged.connect.platforms[0].token_encrypted.is_none(),
911            "ciphertext must be cleared too, not resurrected via hydration"
912        );
913    }
914
915    #[test]
916    fn null_subagents_claude_code_binary_is_unset_and_does_not_crash_the_patch() {
917        // The exact motivating case from issue #505, exercised through the
918        // full `build_merged_config` pipeline (not just `deep_merge_json` in
919        // isolation): an `Option<String>` field written once must become
920        // un-settable via a later PATCH.
921        let mut current = Config::default();
922        current.subagents_mut().claude_code_binary = Some("/usr/local/bin/claude".to_string());
923        current.subagents_mut().executor = Some("claude_code".to_string());
924
925        let patch: Map<String, Value> =
926            serde_json::from_str(r#"{"subagents":{"claude_code_binary":null}}"#).unwrap();
927        let merged = build_merged_config(&current, patch).expect("merge must not error");
928
929        assert_eq!(merged.subagents().claude_code_binary, None);
930        // Sibling field untouched by the patch survives — proves this was a
931        // surgical field-level delete, not a whole-subtree reset.
932        assert_eq!(merged.subagents().executor, Some("claude_code".to_string()));
933    }
934}