1use serde_json::{Map, Value};
21
22use crate::error::AppError;
23use bamboo_config::patch::ProviderApiKeyIntents;
24use bamboo_llm::Config;
25
26pub 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 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 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 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 let api_key_intents = provider_api_key_intents(&patch_obj);
155 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 clear_provider_ciphertext_for_explicit_clears(&mut new_config, &api_key_intents);
172 clear_notification_ciphertext_for_explicit_clears(&mut new_config, ¬ification_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 new_config.preserve_env_sourced_provider_keys(current);
188 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 #[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(¤t, 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 #[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(¤t, 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 #[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(¤t, 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 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 #[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(¤t, 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 #[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(¤t, 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 #[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(¤t, 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 #[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(¤t, 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 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 ¤t,
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 let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
482
483 let merged =
484 merge_notifications_patch(¤t, 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 ¤t,
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 ¤t,
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 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 ¤t,
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 ¤t,
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 let current = config_with_connect_platform("telegram", "tg-secret-token");
626
627 let merged =
628 merge_connect_patch(¤t, 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 ¤t,
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 ¤t,
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 #[test]
680 fn null_instance_api_key_clear_wins_over_in_memory_ciphertext() {
681 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(¤t, 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 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(¤t, 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 let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
753
754 let merged =
755 merge_notifications_patch(¤t, 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 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 ¤t,
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 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(¤t, patch).expect("merge must not error");
815
816 assert_eq!(merged.subagents.claude_code_binary, None);
817 assert_eq!(merged.subagents.executor, Some("claude_code".to_string()));
820 }
821}