use serde_json::{Map, Value};
use crate::error::AppError;
use bamboo_config::patch::ProviderApiKeyIntents;
use bamboo_llm::Config;
pub use bamboo_config::patch::{
clear_connect_ciphertext_for_explicit_clears,
clear_notification_ciphertext_for_explicit_clears,
clear_provider_ciphertext_for_explicit_clears, connect_secret_intents, deep_merge_json,
domains_for_root_patch, effects_for_root_patch, is_masked_api_key, notification_secret_intents,
preserve_masked_connect_secrets, preserve_masked_notification_secrets,
preserve_masked_provider_api_keys, preserve_unpatched_provider_secrets,
provider_api_key_intents, sanitize_root_patch, ConnectSecretIntents, DomainChanges,
NotificationSecretIntents, PatchEffects, ReloadMode,
};
pub fn sync_provider_api_keys_encrypted_for_patch(
config: &mut Config,
intents: &ProviderApiKeyIntents,
) -> Result<(), AppError> {
for name in intents.providers.iter() {
match name.as_str() {
"openai" => {
if let Some(openai) = config.providers_mut().openai.as_mut() {
if !openai.api_key_from_env {
let api_key = openai.api_key.trim();
openai.api_key_encrypted = if api_key.is_empty() {
None
} else {
Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
AppError::InternalError(anyhow::anyhow!(
"Failed to encrypt OpenAI api_key: {e}"
))
})?)
};
}
}
}
"anthropic" => {
if let Some(anthropic) = config.providers_mut().anthropic.as_mut() {
if !anthropic.api_key_from_env {
let api_key = anthropic.api_key.trim();
anthropic.api_key_encrypted = if api_key.is_empty() {
None
} else {
Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
AppError::InternalError(anyhow::anyhow!(
"Failed to encrypt Anthropic api_key: {e}"
))
})?)
};
}
}
}
"gemini" => {
if let Some(gemini) = config.providers_mut().gemini.as_mut() {
if !gemini.api_key_from_env {
let api_key = gemini.api_key.trim();
gemini.api_key_encrypted = if api_key.is_empty() {
None
} else {
Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
AppError::InternalError(anyhow::anyhow!(
"Failed to encrypt Gemini api_key: {e}"
))
})?)
};
}
}
}
"bodhi" => {
if let Some(bodhi) = config.providers_mut().bodhi.as_mut() {
let api_key = bodhi.api_key.trim();
bodhi.api_key_encrypted = if api_key.is_empty() {
None
} else {
Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
AppError::InternalError(anyhow::anyhow!(
"Failed to encrypt Bodhi api_key: {e}"
))
})?)
};
}
}
_ => {}
}
}
for instance_id in intents.provider_instances.iter() {
if let Some(instance) = config.provider_instances.get_mut(instance_id) {
let api_key = instance.api_key.trim();
instance.api_key_encrypted = if api_key.is_empty() {
None
} else {
Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
AppError::InternalError(anyhow::anyhow!(
"Failed to encrypt provider instance api_key for '{instance_id}': {e}"
))
})?)
};
}
}
Ok(())
}
pub fn assert_json_object(value: Value) -> Result<Map<String, Value>, AppError> {
match value {
Value::Object(map) => Ok(map),
_ => Err(AppError::BadRequest(
"config.json must be a JSON object".to_string(),
)),
}
}
pub fn build_merged_config(
current: &Config,
patch_obj: Map<String, Value>,
) -> Result<Config, AppError> {
let api_key_intents = provider_api_key_intents(&patch_obj);
let notification_intents = notification_secret_intents(&patch_obj);
let connect_intents = connect_secret_intents(&patch_obj);
let mut merged = current
.to_compatibility_value()
.map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to serialize config: {e}")))?;
deep_merge_json(&mut merged, Value::Object(patch_obj));
let mut new_config: Config = serde_json::from_value(merged)
.map_err(|e| AppError::BadRequest(format!("Invalid configuration JSON: {e}")))?;
clear_provider_ciphertext_for_explicit_clears(&mut new_config, &api_key_intents);
clear_notification_ciphertext_for_explicit_clears(&mut new_config, ¬ification_intents);
clear_connect_ciphertext_for_explicit_clears(&mut new_config, &connect_intents);
new_config.hydrate_proxy_auth_from_encrypted();
new_config.hydrate_provider_api_keys_from_encrypted();
new_config.hydrate_provider_instance_api_keys_from_encrypted();
new_config.hydrate_mcp_secrets_from_encrypted();
new_config.hydrate_env_vars_from_encrypted();
new_config.hydrate_notifications_from_encrypted();
new_config.hydrate_connect_platform_tokens_from_encrypted();
new_config.preserve_env_sourced_provider_keys(current);
preserve_unpatched_provider_secrets(&mut new_config, current, &api_key_intents);
new_config.normalize_tool_settings();
new_config.normalize_skill_settings();
new_config.normalize_plugin_trust_settings();
Ok(new_config)
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_config::OpenAIConfig;
fn env_sourced_openai_config() -> Config {
let mut config = Config::default();
config.providers_mut().openai = Some(OpenAIConfig {
api_key: "sk-env-secret".to_string(),
api_key_from_env: true,
..Default::default()
});
config
}
#[test]
fn clearing_env_sourced_key_does_not_persist_the_secret() {
let current = env_sourced_openai_config();
let patch: Map<String, Value> =
serde_json::from_str(r#"{"providers":{"openai":{"api_key":""}}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
assert!(
intents.providers.contains("openai"),
"empty string is a clear intent"
);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let openai = merged.providers().openai.as_ref().unwrap();
assert!(
openai.api_key_encrypted.is_none(),
"env secret must NOT be encrypted to disk on a clear"
);
assert!(openai.api_key_from_env, "still flagged env-sourced");
assert_eq!(openai.api_key, "sk-env-secret", "live env key preserved");
}
#[test]
fn explicit_new_key_overrides_env_and_persists() {
let current = env_sourced_openai_config();
let patch: Map<String, Value> =
serde_json::from_str(r#"{"providers":{"openai":{"api_key":"sk-brand-new"}}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let openai = merged.providers().openai.as_ref().unwrap();
assert_eq!(openai.api_key, "sk-brand-new", "explicit override wins");
assert!(!openai.api_key_from_env, "override clears the env flag");
assert!(
openai.api_key_encrypted.is_some(),
"a real override is encrypted/persisted"
);
}
#[test]
fn unrelated_patch_preserves_env_key() {
let current = env_sourced_openai_config();
let patch: Map<String, Value> =
serde_json::from_str(r#"{"providers":{"openai":{"model":"gpt-x"}}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
assert!(
!intents.providers.contains("openai"),
"no api_key in patch → no intent"
);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let openai = merged.providers().openai.as_ref().unwrap();
assert_eq!(
openai.api_key, "sk-env-secret",
"env key preserved across unrelated patch"
);
assert!(openai.api_key_from_env);
assert!(openai.api_key_encrypted.is_none(), "still not persisted");
}
fn config_with_plaintext_only_instance(api_key: &str) -> Config {
let mut config = Config::default();
let instance: bamboo_config::ProviderInstanceConfig =
serde_json::from_value(serde_json::json!({
"provider_type": "openai",
"api_key": api_key,
}))
.expect("valid instance");
config
.provider_instances
.insert("uuid-1".to_string(), instance);
config
}
#[test]
fn unrelated_patch_preserves_plaintext_only_instance_key() {
let current = config_with_plaintext_only_instance("sk-instance-live");
let patch: Map<String, Value> =
serde_json::from_str(r#"{"features":{"provider_model_ref":true}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
assert!(intents.provider_instances.is_empty());
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let instance = merged.provider_instances.get("uuid-1").expect("instance");
assert_eq!(
instance.api_key, "sk-instance-live",
"an unrelated settings PATCH must not lose the instance key (#516)"
);
}
#[test]
fn explicit_instance_key_clear_still_clears() {
let current = config_with_plaintext_only_instance("sk-old");
let patch: Map<String, Value> =
serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
assert!(
intents.provider_instances.contains("uuid-1"),
"empty string is a clear intent"
);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let instance = merged.provider_instances.get("uuid-1").expect("instance");
assert!(instance.api_key.is_empty(), "explicit clear must win");
assert!(instance.api_key_encrypted.is_none());
}
#[test]
fn explicit_instance_key_clear_wins_over_in_memory_ciphertext() {
let mut current = config_with_plaintext_only_instance("sk-old");
current.refresh_encrypted_secrets().expect("refresh");
assert!(
current.provider_instances["uuid-1"]
.api_key_encrypted
.is_some(),
"precondition: live config holds ciphertext"
);
let patch: Map<String, Value> =
serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let instance = merged.provider_instances.get("uuid-1").expect("instance");
assert!(instance.api_key.is_empty(), "explicit clear must win");
assert!(
instance.api_key_encrypted.is_none(),
"ciphertext must be cleared too"
);
}
#[test]
fn unrelated_patch_preserves_provider_instance_ciphertext() {
let mut current = config_with_plaintext_only_instance("sk-instance-secret");
current.refresh_encrypted_secrets().expect("refresh");
let prev_ciphertext = current.provider_instances["uuid-1"]
.api_key_encrypted
.clone()
.expect("current should have ciphertext");
let patch: Map<String, Value> =
serde_json::from_str(r#"{"http_proxy":"http://example.invalid:8080"}"#).unwrap();
let intents = provider_api_key_intents(&patch);
assert!(intents.provider_instances.is_empty());
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let instance = &merged.provider_instances["uuid-1"];
assert_eq!(
instance.api_key, "sk-instance-secret",
"plaintext must survive an unrelated save"
);
assert_eq!(
instance.api_key_encrypted.as_deref(),
Some(prev_ciphertext.as_str()),
"ciphertext must survive an unrelated save"
);
}
fn config_with_notification_secrets(ntfy_token: &str, bark_key: &str) -> Config {
let mut config = Config::default();
config.notifications.ntfy.token = Some(ntfy_token.to_string());
config.notifications.bark.device_key = Some(bark_key.to_string());
config.refresh_encrypted_secrets().expect("refresh");
config
}
fn merge_notifications_patch(current: &Config, patch_json: &str) -> Config {
let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
preserve_masked_notification_secrets(&mut patch_obj, current);
let mut merged = build_merged_config(current, patch_obj).expect("merge");
merged.refresh_encrypted_secrets().expect("refresh");
merged
}
#[test]
fn explicit_notification_secret_clear_wins_over_in_memory_ciphertext() {
let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
assert!(current.notifications.ntfy.token_encrypted.is_some());
assert!(current.notifications.bark.device_key_encrypted.is_some());
let merged = merge_notifications_patch(
¤t,
r#"{"notifications":{"ntfy":{"token":""},"bark":{"device_key":""}}}"#,
);
assert!(
merged
.notifications
.ntfy
.token
.as_deref()
.unwrap_or("")
.is_empty(),
"explicit clear must win"
);
assert!(
merged.notifications.ntfy.token_encrypted.is_none(),
"ciphertext must be cleared too (#521)"
);
assert!(merged
.notifications
.bark
.device_key
.as_deref()
.unwrap_or("")
.is_empty());
assert!(merged.notifications.bark.device_key_encrypted.is_none());
}
#[test]
fn unrelated_patch_preserves_notification_secrets() {
let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
let merged =
merge_notifications_patch(¤t, r#"{"http_proxy":"http://example.invalid:8080"}"#);
assert_eq!(
merged.notifications.ntfy.token.as_deref(),
Some("ntfy-secret"),
"an unrelated settings PATCH must not lose the ntfy token"
);
assert!(merged.notifications.ntfy.token_encrypted.is_some());
assert_eq!(
merged.notifications.bark.device_key.as_deref(),
Some("bark-secret"),
"an unrelated settings PATCH must not lose the Bark device key"
);
assert!(merged.notifications.bark.device_key_encrypted.is_some());
}
#[test]
fn masked_notification_secret_placeholder_preserves_value() {
let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
let merged = merge_notifications_patch(
¤t,
r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
);
assert_eq!(
merged.notifications.ntfy.token.as_deref(),
Some("ntfy-secret")
);
assert!(merged.notifications.ntfy.token_encrypted.is_some());
assert_eq!(
merged.notifications.bark.device_key.as_deref(),
Some("bark-secret")
);
assert!(merged.notifications.bark.device_key_encrypted.is_some());
}
#[test]
fn new_notification_secret_value_replaces_and_encrypts() {
let current = config_with_notification_secrets("ntfy-old", "bark-old");
let merged = merge_notifications_patch(
¤t,
r#"{"notifications":{"ntfy":{"token":"ntfy-new"},"bark":{"device_key":"bark-new"}}}"#,
);
assert_eq!(merged.notifications.ntfy.token.as_deref(), Some("ntfy-new"));
assert!(merged.notifications.ntfy.token_encrypted.is_some());
assert_eq!(
merged.notifications.bark.device_key.as_deref(),
Some("bark-new")
);
assert!(merged.notifications.bark.device_key_encrypted.is_some());
}
fn config_with_connect_platform(platform_type: &str, token: &str) -> Config {
let mut config = Config::default();
let platform: bamboo_config::ConnectPlatformConfig =
serde_json::from_value(serde_json::json!({
"type": platform_type,
"token": token,
}))
.expect("valid platform");
config.connect.platforms = vec![platform];
config.refresh_encrypted_secrets().expect("refresh");
config
}
fn merge_connect_patch(current: &Config, patch_json: &str) -> Config {
let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
preserve_masked_connect_secrets(&mut patch_obj, current);
let mut merged = build_merged_config(current, patch_obj).expect("merge");
merged.refresh_encrypted_secrets().expect("refresh");
merged
}
#[test]
fn explicit_connect_token_clear_wins_over_in_memory_ciphertext() {
let current = config_with_connect_platform("telegram", "tg-secret-token");
assert!(current.connect.platforms[0].token_encrypted.is_some());
let merged = merge_connect_patch(
¤t,
r#"{"connect":{"platforms":[{"type":"telegram","token":""}]}}"#,
);
assert!(
merged.connect.platforms[0]
.token
.as_deref()
.unwrap_or("")
.is_empty(),
"explicit clear must win"
);
assert!(
merged.connect.platforms[0].token_encrypted.is_none(),
"ciphertext must be cleared too (#521)"
);
}
#[test]
fn explicit_connect_app_secret_clear_wins_over_in_memory_ciphertext() {
let mut current = Config::default();
let platform: bamboo_config::ConnectPlatformConfig =
serde_json::from_value(serde_json::json!({
"type": "feishu",
"app_id": "cli_x",
"app_secret": "feishu-secret",
}))
.expect("valid platform");
current.connect.platforms = vec![platform];
current.refresh_encrypted_secrets().expect("refresh");
assert!(current.connect.platforms[0].app_secret_encrypted.is_some());
let merged = merge_connect_patch(
¤t,
r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":""}]}}"#,
);
assert!(
merged.connect.platforms[0]
.app_secret
.as_deref()
.unwrap_or("")
.is_empty(),
"explicit clear must win"
);
assert!(
merged.connect.platforms[0].app_secret_encrypted.is_none(),
"ciphertext must be cleared too (#521)"
);
}
#[test]
fn unrelated_patch_preserves_connect_token() {
let current = config_with_connect_platform("telegram", "tg-secret-token");
let merged =
merge_connect_patch(¤t, r#"{"http_proxy":"http://example.invalid:8080"}"#);
assert_eq!(
merged.connect.platforms[0].token.as_deref(),
Some("tg-secret-token"),
"an unrelated settings PATCH must not lose the connect platform token"
);
assert!(merged.connect.platforms[0].token_encrypted.is_some());
}
#[test]
fn masked_connect_token_placeholder_preserves_value() {
let current = config_with_connect_platform("telegram", "tg-secret-token");
let merged = merge_connect_patch(
¤t,
r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
);
assert_eq!(
merged.connect.platforms[0].token.as_deref(),
Some("tg-secret-token")
);
assert!(merged.connect.platforms[0].token_encrypted.is_some());
}
#[test]
fn new_connect_token_value_replaces_and_encrypts() {
let current = config_with_connect_platform("telegram", "tg-old-token");
let merged = merge_connect_patch(
¤t,
r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-new-token"}]}}"#,
);
assert_eq!(
merged.connect.platforms[0].token.as_deref(),
Some("tg-new-token")
);
assert!(merged.connect.platforms[0].token_encrypted.is_some());
}
#[test]
fn null_instance_api_key_clear_wins_over_in_memory_ciphertext() {
let mut current = config_with_plaintext_only_instance("sk-old");
current.refresh_encrypted_secrets().expect("refresh");
assert!(
current.provider_instances["uuid-1"]
.api_key_encrypted
.is_some(),
"precondition: live config holds ciphertext"
);
let patch: Map<String, Value> =
serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":null}}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
assert!(
intents.provider_instances.contains("uuid-1"),
"null must register as a clear intent, same as \"\""
);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
let instance = merged.provider_instances.get("uuid-1").expect("instance");
assert!(instance.api_key.is_empty(), "null clear must win");
assert!(
instance.api_key_encrypted.is_none(),
"ciphertext must be cleared too, not resurrected via hydration"
);
}
#[test]
fn null_deletes_a_whole_provider_instance_entry() {
let mut current = config_with_plaintext_only_instance("sk-keep-me");
let second: bamboo_config::ProviderInstanceConfig =
serde_json::from_value(serde_json::json!({
"provider_type": "anthropic",
"label": "Delete Me",
}))
.expect("valid instance");
current
.provider_instances
.insert("uuid-2".to_string(), second);
let patch: Map<String, Value> =
serde_json::from_str(r#"{"provider_instances":{"uuid-2":null}}"#).unwrap();
let intents = provider_api_key_intents(&patch);
let mut merged = build_merged_config(¤t, patch).expect("merge");
sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
assert!(
!merged.provider_instances.contains_key("uuid-2"),
"the null-targeted instance must be gone"
);
assert_eq!(
merged
.provider_instances
.get("uuid-1")
.map(|i| &i.provider_type),
Some(&"openai".to_string()),
"the untouched sibling instance must survive"
);
}
#[test]
fn null_ntfy_token_clear_wins_over_in_memory_ciphertext() {
let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
let merged =
merge_notifications_patch(¤t, r#"{"notifications":{"ntfy":{"token":null}}}"#);
assert!(
merged
.notifications
.ntfy
.token
.as_deref()
.unwrap_or("")
.is_empty(),
"null clear must win"
);
assert!(
merged.notifications.ntfy.token_encrypted.is_none(),
"ciphertext must be cleared too, not resurrected via hydration"
);
assert_eq!(
merged.notifications.bark.device_key.as_deref(),
Some("bark-secret")
);
assert!(merged.notifications.bark.device_key_encrypted.is_some());
}
#[test]
fn null_connect_token_clear_wins_over_in_memory_ciphertext() {
let current = config_with_connect_platform("telegram", "tg-old-token");
let merged = merge_connect_patch(
¤t,
r#"{"connect":{"platforms":[{"type":"telegram","token":null}]}}"#,
);
assert!(
merged.connect.platforms[0]
.token
.as_deref()
.unwrap_or("")
.is_empty(),
"null clear must win"
);
assert!(
merged.connect.platforms[0].token_encrypted.is_none(),
"ciphertext must be cleared too, not resurrected via hydration"
);
}
#[test]
fn null_subagents_claude_code_binary_is_unset_and_does_not_crash_the_patch() {
let mut current = Config::default();
current.subagents_mut().claude_code_binary = Some("/usr/local/bin/claude".to_string());
current.subagents_mut().executor = Some("claude_code".to_string());
let patch: Map<String, Value> =
serde_json::from_str(r#"{"subagents":{"claude_code_binary":null}}"#).unwrap();
let merged = build_merged_config(¤t, patch).expect("merge must not error");
assert_eq!(merged.subagents().claude_code_binary, None);
assert_eq!(merged.subagents().executor, Some("claude_code".to_string()));
}
}