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_provider_secrets,
35    provider_api_key_intents, sanitize_root_patch, ConnectSecretIntents, DomainChanges,
36    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.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.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.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.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
147pub fn build_merged_config(
148    current: &Config,
149    patch_obj: Map<String, Value>,
150) -> Result<Config, AppError> {
151    // Captured before the merge consumes the patch: which providers/instances
152    // this patch explicitly sets or clears — those must NOT get their dropped
153    // key carried forward below (#516).
154    let api_key_intents = provider_api_key_intents(&patch_obj);
155    // Same capture for the other secret domains that flow through this merge
156    // (#521 — ntfy `token` / Bark `device_key` / connect `token` and Feishu
157    // `app_secret`): must be read before the patch is consumed below.
158    let notification_intents = notification_secret_intents(&patch_obj);
159    let connect_intents = connect_secret_intents(&patch_obj);
160
161    let mut merged = serde_json::to_value(current)
162        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to serialize config: {e}")))?;
163
164    deep_merge_json(&mut merged, Value::Object(patch_obj));
165
166    let mut new_config: Config = serde_json::from_value(merged)
167        .map_err(|e| AppError::BadRequest(format!("Invalid configuration JSON: {e}")))?;
168    // An explicit `api_key: ""` clear must drop the round-tripped ciphertext
169    // BEFORE hydration — otherwise hydration refills the plaintext from it and
170    // the subsequent sync/save re-encrypts, silently undoing the clear (#516).
171    clear_provider_ciphertext_for_explicit_clears(&mut new_config, &api_key_intents);
172    // Same treatment for notification/connect secrets (#521) — same rationale,
173    // same ordering requirement (before hydration below).
174    clear_notification_ciphertext_for_explicit_clears(&mut new_config, &notification_intents);
175    clear_connect_ciphertext_for_explicit_clears(&mut new_config, &connect_intents);
176    new_config.hydrate_proxy_auth_from_encrypted();
177    new_config.hydrate_provider_api_keys_from_encrypted();
178    new_config.hydrate_provider_instance_api_keys_from_encrypted();
179    new_config.hydrate_mcp_secrets_from_encrypted();
180    new_config.hydrate_env_vars_from_encrypted();
181    new_config.hydrate_notifications_from_encrypted();
182    new_config.hydrate_connect_platform_tokens_from_encrypted();
183    // The serde round-trip above drops every provider's `#[serde(skip_serializing)]`
184    // `api_key`; hydration only restores ciphertext-backed keys, so an env-sourced
185    // key (no ciphertext, #253) would be silently blanked by any settings PATCH.
186    // Copy env-sourced keys back from the live `current` config. #373.
187    new_config.preserve_env_sourced_provider_keys(current);
188    // Same round-trip hazard for any OTHER plaintext-only key (ciphertext still
189    // `None` in the live config — e.g. a provider instance freshly created via
190    // the instance CRUD endpoints): hydration has nothing to decrypt and the
191    // key would vanish from config.json on the next persist. Carry unpatched
192    // keys forward from the live config. #515/#516.
193    preserve_unpatched_provider_secrets(&mut new_config, current, &api_key_intents);
194    new_config.normalize_tool_settings();
195    new_config.normalize_skill_settings();
196    new_config.normalize_plugin_trust_settings();
197
198    Ok(new_config)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use bamboo_config::OpenAIConfig;
205
206    fn env_sourced_openai_config() -> Config {
207        let mut config = Config::default();
208        config.providers.openai = Some(OpenAIConfig {
209            api_key: "sk-env-secret".to_string(),
210            api_key_from_env: true,
211            ..Default::default()
212        });
213        config
214    }
215
216    // #373: an explicit `api_key: ""` clear of an env-sourced provider must NOT
217    // bake the env secret into config.json. build_merged_config restores the live
218    // env key (api_key_from_env=true), and the from_env guard in
219    // sync_provider_api_keys_encrypted_for_patch must then skip encrypting it.
220    #[test]
221    fn clearing_env_sourced_key_does_not_persist_the_secret() {
222        let current = env_sourced_openai_config();
223        let patch: Map<String, Value> =
224            serde_json::from_str(r#"{"providers":{"openai":{"api_key":""}}}"#).unwrap();
225        let intents = provider_api_key_intents(&patch);
226        assert!(
227            intents.providers.contains("openai"),
228            "empty string is a clear intent"
229        );
230
231        let mut merged = build_merged_config(&current, patch).expect("merge");
232        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
233
234        let openai = merged.providers.openai.as_ref().unwrap();
235        assert!(
236            openai.api_key_encrypted.is_none(),
237            "env secret must NOT be encrypted to disk on a clear"
238        );
239        assert!(openai.api_key_from_env, "still flagged env-sourced");
240        assert_eq!(openai.api_key, "sk-env-secret", "live env key preserved");
241    }
242
243    // A genuine NEW key for an env-sourced provider resets api_key_from_env=false
244    // and IS persisted (the explicit override wins).
245    #[test]
246    fn explicit_new_key_overrides_env_and_persists() {
247        let current = env_sourced_openai_config();
248        let patch: Map<String, Value> =
249            serde_json::from_str(r#"{"providers":{"openai":{"api_key":"sk-brand-new"}}}"#).unwrap();
250        let intents = provider_api_key_intents(&patch);
251
252        let mut merged = build_merged_config(&current, patch).expect("merge");
253        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
254
255        let openai = merged.providers.openai.as_ref().unwrap();
256        assert_eq!(openai.api_key, "sk-brand-new", "explicit override wins");
257        assert!(!openai.api_key_from_env, "override clears the env flag");
258        assert!(
259            openai.api_key_encrypted.is_some(),
260            "a real override is encrypted/persisted"
261        );
262    }
263
264    // A PATCH that doesn't touch api_key must not drop the env-sourced key.
265    #[test]
266    fn unrelated_patch_preserves_env_key() {
267        let current = env_sourced_openai_config();
268        let patch: Map<String, Value> =
269            serde_json::from_str(r#"{"providers":{"openai":{"model":"gpt-x"}}}"#).unwrap();
270        let intents = provider_api_key_intents(&patch);
271        assert!(
272            !intents.providers.contains("openai"),
273            "no api_key in patch → no intent"
274        );
275
276        let mut merged = build_merged_config(&current, patch).expect("merge");
277        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
278
279        let openai = merged.providers.openai.as_ref().unwrap();
280        assert_eq!(
281            openai.api_key, "sk-env-secret",
282            "env key preserved across unrelated patch"
283        );
284        assert!(openai.api_key_from_env);
285        assert!(openai.api_key_encrypted.is_none(), "still not persisted");
286    }
287
288    /// The live in-memory state right after `POST /provider-instances`:
289    /// plaintext key, ciphertext still `None` (ciphertext is only ever computed
290    /// on `save_to_dir`'s save-time clone).
291    fn config_with_plaintext_only_instance(api_key: &str) -> Config {
292        let mut config = Config::default();
293        let instance: bamboo_config::ProviderInstanceConfig =
294            serde_json::from_value(serde_json::json!({
295                "provider_type": "openai",
296                "api_key": api_key,
297            }))
298            .expect("valid instance");
299        config
300            .provider_instances
301            .insert("uuid-1".to_string(), instance);
302        config
303    }
304
305    // #516 regression: the lotus instance-mode 保存配置 sends a defaults/features
306    // patch that never mentions the instance. The merge round-trip drops the
307    // `skip_serializing` plaintext, hydration has no ciphertext to restore, and
308    // the freshly-created instance's key was silently wiped from config.json
309    // (config.json.bak kept the previous good copy).
310    #[test]
311    fn unrelated_patch_preserves_plaintext_only_instance_key() {
312        let current = config_with_plaintext_only_instance("sk-instance-live");
313
314        let patch: Map<String, Value> =
315            serde_json::from_str(r#"{"features":{"provider_model_ref":true}}"#).unwrap();
316        let intents = provider_api_key_intents(&patch);
317        assert!(intents.provider_instances.is_empty());
318
319        let mut merged = build_merged_config(&current, patch).expect("merge");
320        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
321
322        let instance = merged.provider_instances.get("uuid-1").expect("instance");
323        assert_eq!(
324            instance.api_key, "sk-instance-live",
325            "an unrelated settings PATCH must not lose the instance key (#516)"
326        );
327    }
328
329    // The carry-forward must not resurrect a key the patch explicitly cleared.
330    #[test]
331    fn explicit_instance_key_clear_still_clears() {
332        let current = config_with_plaintext_only_instance("sk-old");
333
334        let patch: Map<String, Value> =
335            serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
336        let intents = provider_api_key_intents(&patch);
337        assert!(
338            intents.provider_instances.contains("uuid-1"),
339            "empty string is a clear intent"
340        );
341
342        let mut merged = build_merged_config(&current, patch).expect("merge");
343        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
344
345        let instance = merged.provider_instances.get("uuid-1").expect("instance");
346        assert!(instance.api_key.is_empty(), "explicit clear must win");
347        assert!(instance.api_key_encrypted.is_none());
348    }
349
350    // An explicit clear must also win when the live config holds ciphertext in
351    // memory — the normal state now that update_config keeps ciphertext in sync
352    // (#516). Without the pre-hydration ciphertext drop, hydration would refill
353    // the plaintext from the round-tripped ciphertext and sync would re-encrypt
354    // it, silently undoing the clear.
355    #[test]
356    fn explicit_instance_key_clear_wins_over_in_memory_ciphertext() {
357        let mut current = config_with_plaintext_only_instance("sk-old");
358        current.refresh_encrypted_secrets().expect("refresh");
359        assert!(
360            current.provider_instances["uuid-1"]
361                .api_key_encrypted
362                .is_some(),
363            "precondition: live config holds ciphertext"
364        );
365
366        let patch: Map<String, Value> =
367            serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
368        let intents = provider_api_key_intents(&patch);
369
370        let mut merged = build_merged_config(&current, patch).expect("merge");
371        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
372
373        let instance = merged.provider_instances.get("uuid-1").expect("instance");
374        assert!(instance.api_key.is_empty(), "explicit clear must win");
375        assert!(
376            instance.api_key_encrypted.is_none(),
377            "ciphertext must be cleared too"
378        );
379    }
380
381    // #515: an unrelated settings-save PATCH must also preserve an instance
382    // whose live config already holds ciphertext in memory (the normal state
383    // now that update_config keeps ciphertext in sync) — both the plaintext
384    // AND the exact stored ciphertext must survive the round trip.
385    #[test]
386    fn unrelated_patch_preserves_provider_instance_ciphertext() {
387        let mut current = config_with_plaintext_only_instance("sk-instance-secret");
388        current.refresh_encrypted_secrets().expect("refresh");
389        let prev_ciphertext = current.provider_instances["uuid-1"]
390            .api_key_encrypted
391            .clone()
392            .expect("current should have ciphertext");
393
394        let patch: Map<String, Value> =
395            serde_json::from_str(r#"{"http_proxy":"http://example.invalid:8080"}"#).unwrap();
396        let intents = provider_api_key_intents(&patch);
397        assert!(intents.provider_instances.is_empty());
398
399        let mut merged = build_merged_config(&current, patch).expect("merge");
400        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
401
402        let instance = &merged.provider_instances["uuid-1"];
403        assert_eq!(
404            instance.api_key, "sk-instance-secret",
405            "plaintext must survive an unrelated save"
406        );
407        assert_eq!(
408            instance.api_key_encrypted.as_deref(),
409            Some(prev_ciphertext.as_str()),
410            "ciphertext must survive an unrelated save"
411        );
412    }
413
414    // ── #521: full-pipeline coverage for notification secrets ──────────
415    //
416    // Mirrors set_bamboo_config's actual call order: preserve_masked_* first
417    // (mutating the patch against `current`), THEN build_merged_config, THEN
418    // the post-merge re-encrypt (`refresh_notifications_encrypted`, run in
419    // production by `Config::refresh_encrypted_secrets` inside
420    // `AppState::update_config`).
421
422    fn config_with_notification_secrets(ntfy_token: &str, bark_key: &str) -> Config {
423        let mut config = Config::default();
424        config.notifications.ntfy.token = Some(ntfy_token.to_string());
425        config.notifications.bark.device_key = Some(bark_key.to_string());
426        config.refresh_encrypted_secrets().expect("refresh");
427        config
428    }
429
430    fn merge_notifications_patch(current: &Config, patch_json: &str) -> Config {
431        let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
432        preserve_masked_notification_secrets(&mut patch_obj, current);
433        let mut merged = build_merged_config(current, patch_obj).expect("merge");
434        merged.refresh_encrypted_secrets().expect("refresh");
435        merged
436    }
437
438    #[test]
439    fn explicit_notification_secret_clear_wins_over_in_memory_ciphertext() {
440        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
441        assert!(current.notifications.ntfy.token_encrypted.is_some());
442        assert!(current.notifications.bark.device_key_encrypted.is_some());
443
444        let merged = merge_notifications_patch(
445            &current,
446            r#"{"notifications":{"ntfy":{"token":""},"bark":{"device_key":""}}}"#,
447        );
448
449        assert!(
450            merged
451                .notifications
452                .ntfy
453                .token
454                .as_deref()
455                .unwrap_or("")
456                .is_empty(),
457            "explicit clear must win"
458        );
459        assert!(
460            merged.notifications.ntfy.token_encrypted.is_none(),
461            "ciphertext must be cleared too (#521)"
462        );
463        assert!(merged
464            .notifications
465            .bark
466            .device_key
467            .as_deref()
468            .unwrap_or("")
469            .is_empty());
470        assert!(merged.notifications.bark.device_key_encrypted.is_none());
471    }
472
473    #[test]
474    fn unrelated_patch_preserves_notification_secrets() {
475        // NOTE: unlike providers (whose ciphertext is only touched by the
476        // explicit-intent-gated `sync_provider_api_keys_encrypted_for_patch`),
477        // notification ciphertext is unconditionally recomputed by
478        // `refresh_encrypted_secrets` on every write (pre-existing,
479        // out-of-scope-for-#521 design) — so only plaintext survival and
480        // ciphertext presence are asserted, not exact ciphertext bytes.
481        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
482
483        let merged =
484            merge_notifications_patch(&current, r#"{"http_proxy":"http://example.invalid:8080"}"#);
485
486        assert_eq!(
487            merged.notifications.ntfy.token.as_deref(),
488            Some("ntfy-secret"),
489            "an unrelated settings PATCH must not lose the ntfy token"
490        );
491        assert!(merged.notifications.ntfy.token_encrypted.is_some());
492        assert_eq!(
493            merged.notifications.bark.device_key.as_deref(),
494            Some("bark-secret"),
495            "an unrelated settings PATCH must not lose the Bark device key"
496        );
497        assert!(merged.notifications.bark.device_key_encrypted.is_some());
498    }
499
500    #[test]
501    fn masked_notification_secret_placeholder_preserves_value() {
502        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
503
504        let merged = merge_notifications_patch(
505            &current,
506            r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
507        );
508
509        assert_eq!(
510            merged.notifications.ntfy.token.as_deref(),
511            Some("ntfy-secret")
512        );
513        assert!(merged.notifications.ntfy.token_encrypted.is_some());
514        assert_eq!(
515            merged.notifications.bark.device_key.as_deref(),
516            Some("bark-secret")
517        );
518        assert!(merged.notifications.bark.device_key_encrypted.is_some());
519    }
520
521    #[test]
522    fn new_notification_secret_value_replaces_and_encrypts() {
523        let current = config_with_notification_secrets("ntfy-old", "bark-old");
524
525        let merged = merge_notifications_patch(
526            &current,
527            r#"{"notifications":{"ntfy":{"token":"ntfy-new"},"bark":{"device_key":"bark-new"}}}"#,
528        );
529
530        assert_eq!(merged.notifications.ntfy.token.as_deref(), Some("ntfy-new"));
531        assert!(merged.notifications.ntfy.token_encrypted.is_some());
532        assert_eq!(
533            merged.notifications.bark.device_key.as_deref(),
534            Some("bark-new")
535        );
536        assert!(merged.notifications.bark.device_key_encrypted.is_some());
537    }
538
539    // ── #521: full-pipeline coverage for connect platform secrets ──────
540
541    fn config_with_connect_platform(platform_type: &str, token: &str) -> Config {
542        let mut config = Config::default();
543        let platform: bamboo_config::ConnectPlatformConfig =
544            serde_json::from_value(serde_json::json!({
545                "type": platform_type,
546                "token": token,
547            }))
548            .expect("valid platform");
549        config.connect.platforms = vec![platform];
550        config.refresh_encrypted_secrets().expect("refresh");
551        config
552    }
553
554    fn merge_connect_patch(current: &Config, patch_json: &str) -> Config {
555        let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
556        preserve_masked_connect_secrets(&mut patch_obj, current);
557        let mut merged = build_merged_config(current, patch_obj).expect("merge");
558        merged.refresh_encrypted_secrets().expect("refresh");
559        merged
560    }
561
562    #[test]
563    fn explicit_connect_token_clear_wins_over_in_memory_ciphertext() {
564        let current = config_with_connect_platform("telegram", "tg-secret-token");
565        assert!(current.connect.platforms[0].token_encrypted.is_some());
566
567        let merged = merge_connect_patch(
568            &current,
569            r#"{"connect":{"platforms":[{"type":"telegram","token":""}]}}"#,
570        );
571
572        assert!(
573            merged.connect.platforms[0]
574                .token
575                .as_deref()
576                .unwrap_or("")
577                .is_empty(),
578            "explicit clear must win"
579        );
580        assert!(
581            merged.connect.platforms[0].token_encrypted.is_none(),
582            "ciphertext must be cleared too (#521)"
583        );
584    }
585
586    #[test]
587    fn explicit_connect_app_secret_clear_wins_over_in_memory_ciphertext() {
588        let mut current = Config::default();
589        let platform: bamboo_config::ConnectPlatformConfig =
590            serde_json::from_value(serde_json::json!({
591                "type": "feishu",
592                "app_id": "cli_x",
593                "app_secret": "feishu-secret",
594            }))
595            .expect("valid platform");
596        current.connect.platforms = vec![platform];
597        current.refresh_encrypted_secrets().expect("refresh");
598        assert!(current.connect.platforms[0].app_secret_encrypted.is_some());
599
600        let merged = merge_connect_patch(
601            &current,
602            r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":""}]}}"#,
603        );
604
605        assert!(
606            merged.connect.platforms[0]
607                .app_secret
608                .as_deref()
609                .unwrap_or("")
610                .is_empty(),
611            "explicit clear must win"
612        );
613        assert!(
614            merged.connect.platforms[0].app_secret_encrypted.is_none(),
615            "ciphertext must be cleared too (#521)"
616        );
617    }
618
619    #[test]
620    fn unrelated_patch_preserves_connect_token() {
621        // Same NOTE as `unrelated_patch_preserves_notification_secrets`: connect
622        // ciphertext is unconditionally recomputed by `refresh_encrypted_secrets`
623        // on every write, so only plaintext survival + ciphertext presence are
624        // asserted.
625        let current = config_with_connect_platform("telegram", "tg-secret-token");
626
627        let merged =
628            merge_connect_patch(&current, r#"{"http_proxy":"http://example.invalid:8080"}"#);
629
630        assert_eq!(
631            merged.connect.platforms[0].token.as_deref(),
632            Some("tg-secret-token"),
633            "an unrelated settings PATCH must not lose the connect platform token"
634        );
635        assert!(merged.connect.platforms[0].token_encrypted.is_some());
636    }
637
638    #[test]
639    fn masked_connect_token_placeholder_preserves_value() {
640        let current = config_with_connect_platform("telegram", "tg-secret-token");
641
642        let merged = merge_connect_patch(
643            &current,
644            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
645        );
646
647        assert_eq!(
648            merged.connect.platforms[0].token.as_deref(),
649            Some("tg-secret-token")
650        );
651        assert!(merged.connect.platforms[0].token_encrypted.is_some());
652    }
653
654    #[test]
655    fn new_connect_token_value_replaces_and_encrypts() {
656        let current = config_with_connect_platform("telegram", "tg-old-token");
657
658        let merged = merge_connect_patch(
659            &current,
660            r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-new-token"}]}}"#,
661        );
662
663        assert_eq!(
664            merged.connect.platforms[0].token.as_deref(),
665            Some("tg-new-token")
666        );
667        assert!(merged.connect.platforms[0].token_encrypted.is_some());
668    }
669
670    // ── #505: RFC7386-style null-delete through the FULL production pipeline ──
671    //
672    // These mirror the existing `""`-clear tests above (same helpers, same
673    // call order: preserve_masked_* → build_merged_config →
674    // sync_provider_api_keys_encrypted_for_patch / refresh_encrypted_secrets)
675    // but exercise a `null` clear instead, proving the new delete semantics
676    // compose correctly with the #516/#521 secret machinery end-to-end, not
677    // just at the `bamboo-config`-crate unit level.
678
679    #[test]
680    fn null_instance_api_key_clear_wins_over_in_memory_ciphertext() {
681        // Same scenario as `explicit_instance_key_clear_wins_over_in_memory_ciphertext`
682        // above, but the client sends `null` instead of `""`.
683        let mut current = config_with_plaintext_only_instance("sk-old");
684        current.refresh_encrypted_secrets().expect("refresh");
685        assert!(
686            current.provider_instances["uuid-1"]
687                .api_key_encrypted
688                .is_some(),
689            "precondition: live config holds ciphertext"
690        );
691
692        let patch: Map<String, Value> =
693            serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":null}}}"#).unwrap();
694        let intents = provider_api_key_intents(&patch);
695        assert!(
696            intents.provider_instances.contains("uuid-1"),
697            "null must register as a clear intent, same as \"\""
698        );
699
700        let mut merged = build_merged_config(&current, patch).expect("merge");
701        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
702
703        let instance = merged.provider_instances.get("uuid-1").expect("instance");
704        assert!(instance.api_key.is_empty(), "null clear must win");
705        assert!(
706            instance.api_key_encrypted.is_none(),
707            "ciphertext must be cleared too, not resurrected via hydration"
708        );
709    }
710
711    #[test]
712    fn null_deletes_a_whole_provider_instance_entry() {
713        // The other half of #505: deleting an entire map entry (not just
714        // clearing one field within it). Two instances exist; the patch
715        // null-deletes one by id and must leave the other untouched.
716        let mut current = config_with_plaintext_only_instance("sk-keep-me");
717        let second: bamboo_config::ProviderInstanceConfig =
718            serde_json::from_value(serde_json::json!({
719                "provider_type": "anthropic",
720                "label": "Delete Me",
721            }))
722            .expect("valid instance");
723        current
724            .provider_instances
725            .insert("uuid-2".to_string(), second);
726
727        let patch: Map<String, Value> =
728            serde_json::from_str(r#"{"provider_instances":{"uuid-2":null}}"#).unwrap();
729        let intents = provider_api_key_intents(&patch);
730
731        let mut merged = build_merged_config(&current, patch).expect("merge");
732        sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
733
734        assert!(
735            !merged.provider_instances.contains_key("uuid-2"),
736            "the null-targeted instance must be gone"
737        );
738        assert_eq!(
739            merged
740                .provider_instances
741                .get("uuid-1")
742                .map(|i| &i.provider_type),
743            Some(&"openai".to_string()),
744            "the untouched sibling instance must survive"
745        );
746    }
747
748    #[test]
749    fn null_ntfy_token_clear_wins_over_in_memory_ciphertext() {
750        // Same scenario as `explicit_notification_secret_clear_wins_over_in_memory_ciphertext`
751        // above, but only ntfy is cleared, and via `null` rather than `""`.
752        let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
753
754        let merged =
755            merge_notifications_patch(&current, r#"{"notifications":{"ntfy":{"token":null}}}"#);
756
757        assert!(
758            merged
759                .notifications
760                .ntfy
761                .token
762                .as_deref()
763                .unwrap_or("")
764                .is_empty(),
765            "null clear must win"
766        );
767        assert!(
768            merged.notifications.ntfy.token_encrypted.is_none(),
769            "ciphertext must be cleared too, not resurrected via hydration"
770        );
771        // Sibling secret domain (bark), untouched by the patch, must survive.
772        assert_eq!(
773            merged.notifications.bark.device_key.as_deref(),
774            Some("bark-secret")
775        );
776        assert!(merged.notifications.bark.device_key_encrypted.is_some());
777    }
778
779    #[test]
780    fn null_connect_token_clear_wins_over_in_memory_ciphertext() {
781        let current = config_with_connect_platform("telegram", "tg-old-token");
782
783        let merged = merge_connect_patch(
784            &current,
785            r#"{"connect":{"platforms":[{"type":"telegram","token":null}]}}"#,
786        );
787
788        assert!(
789            merged.connect.platforms[0]
790                .token
791                .as_deref()
792                .unwrap_or("")
793                .is_empty(),
794            "null clear must win"
795        );
796        assert!(
797            merged.connect.platforms[0].token_encrypted.is_none(),
798            "ciphertext must be cleared too, not resurrected via hydration"
799        );
800    }
801
802    #[test]
803    fn null_subagents_claude_code_binary_is_unset_and_does_not_crash_the_patch() {
804        // The exact motivating case from issue #505, exercised through the
805        // full `build_merged_config` pipeline (not just `deep_merge_json` in
806        // isolation): an `Option<String>` field written once must become
807        // un-settable via a later PATCH.
808        let mut current = Config::default();
809        current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
810        current.subagents.executor = Some("claude_code".to_string());
811
812        let patch: Map<String, Value> =
813            serde_json::from_str(r#"{"subagents":{"claude_code_binary":null}}"#).unwrap();
814        let merged = build_merged_config(&current, patch).expect("merge must not error");
815
816        assert_eq!(merged.subagents.claude_code_binary, None);
817        // Sibling field untouched by the patch survives — proves this was a
818        // surgical field-level delete, not a whole-subtree reset.
819        assert_eq!(merged.subagents.executor, Some("claude_code".to_string()));
820    }
821}