1use anyhow::{Context, Result};
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53use std::collections::{BTreeMap, BTreeSet, HashMap};
54use std::io::Write;
55use std::path::PathBuf;
56use std::sync::RwLock;
57
58use crate::agent::tools::normalize_tool_ref;
59use crate::core::keyword_masking::KeywordMaskingConfig;
60use crate::core::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
61use crate::core::ReasoningEffort;
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct EnvVarEntry {
69 pub name: String,
71 #[serde(default)]
74 pub value: String,
75 #[serde(default)]
77 pub secret: bool,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub value_encrypted: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Config {
92 #[serde(default)]
94 pub http_proxy: String,
95 #[serde(default)]
97 pub https_proxy: String,
98 #[serde(skip_serializing)]
102 pub proxy_auth: Option<ProxyAuth>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub proxy_auth_encrypted: Option<String>,
109 #[serde(default)]
111 pub headless_auth: bool,
112
113 #[serde(default = "default_provider")]
115 pub provider: String,
116
117 #[serde(default)]
119 pub providers: ProviderConfigs,
120
121 #[serde(default)]
123 pub server: ServerConfig,
124
125 #[serde(default)]
129 pub keyword_masking: KeywordMaskingConfig,
130
131 #[serde(default)]
135 pub anthropic_model_mapping: AnthropicModelMapping,
136
137 #[serde(default)]
141 pub gemini_model_mapping: GeminiModelMapping,
142
143 #[serde(default)]
148 pub hooks: HooksConfig,
149
150 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
154 pub tools: ToolsConfig,
155
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub env_vars: Vec<EnvVarEntry>,
161
162 #[serde(default, rename = "mcpServers", alias = "mcp")]
168 pub mcp: crate::agent::mcp::McpConfig,
169
170 #[serde(default, flatten)]
176 pub extra: BTreeMap<String, Value>,
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct ProviderConfigs {
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub openai: Option<OpenAIConfig>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub anthropic: Option<AnthropicConfig>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub gemini: Option<GeminiConfig>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub copilot: Option<CopilotConfig>,
196
197 #[serde(default, flatten)]
199 pub extra: BTreeMap<String, Value>,
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct HooksConfig {
205 #[serde(default)]
207 pub image_fallback: ImageFallbackHookConfig,
208}
209
210#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
217pub struct RequestOverridesConfig {
218 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
220 pub common: RequestScopeOverride,
221 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
223 pub endpoints: BTreeMap<String, RequestScopeOverride>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub rules: Vec<ModelRequestRule>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
231pub struct ModelRequestRule {
232 pub model_pattern: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub endpoint: Option<String>,
237 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
239 pub scope: RequestScopeOverride,
240}
241
242#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
244pub struct RequestScopeOverride {
245 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247 pub headers: BTreeMap<String, TemplateExpr>,
248 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub body_patch: Vec<BodyPatch>,
251}
252
253impl RequestScopeOverride {
254 pub fn is_empty(&self) -> bool {
255 self.headers.is_empty() && self.body_patch.is_empty()
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
261pub struct BodyPatch {
262 pub path: String,
264 #[serde(default)]
266 pub op: BodyPatchOp,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub value: Option<PatchValue>,
270}
271
272#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "snake_case")]
275pub enum BodyPatchOp {
276 #[default]
277 Set,
278 Remove,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283#[serde(untagged)]
284pub enum PatchValue {
285 Template(TemplateExpr),
286 Json(Value),
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
291#[serde(untagged)]
292pub enum TemplateExpr {
293 Literal(String),
295 Structured(TemplateExprSpec),
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301#[serde(tag = "type", rename_all = "snake_case")]
302pub enum TemplateExprSpec {
303 Literal { value: String },
305 EnvRef {
307 name: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 fallback: Option<String>,
310 },
311 Generated { generator: GeneratedValue },
313 Format { template: String },
315}
316
317#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "snake_case")]
320pub enum GeneratedValue {
321 Uuid,
322 UnixMs,
323}
324
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
327pub struct ToolsConfig {
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub disabled: Vec<String>,
331}
332
333impl ToolsConfig {
334 fn is_empty(&self) -> bool {
335 self.disabled.is_empty()
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ImageFallbackHookConfig {
345 #[serde(default = "default_true_hooks")]
346 pub enabled: bool,
347
348 #[serde(default = "default_image_fallback_mode")]
350 pub mode: String,
351}
352
353impl Default for ImageFallbackHookConfig {
354 fn default() -> Self {
355 Self {
356 enabled: default_true_hooks(),
357 mode: default_image_fallback_mode(),
358 }
359 }
360}
361
362fn default_image_fallback_mode() -> String {
363 "placeholder".to_string()
364}
365
366fn default_true_hooks() -> bool {
367 false
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct OpenAIConfig {
385 #[serde(default, skip_serializing)]
389 pub api_key: String,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub api_key_encrypted: Option<String>,
393 #[serde(skip_serializing_if = "Option::is_none")]
395 pub base_url: Option<String>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub model: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub fast_model: Option<String>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub vision_model: Option<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub reasoning_effort: Option<ReasoningEffort>,
410
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub responses_only_models: Vec<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub request_overrides: Option<RequestOverridesConfig>,
422
423 #[serde(default, flatten)]
425 pub extra: BTreeMap<String, Value>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct AnthropicConfig {
441 #[serde(default, skip_serializing)]
445 pub api_key: String,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub api_key_encrypted: Option<String>,
449 #[serde(skip_serializing_if = "Option::is_none")]
451 pub base_url: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub model: Option<String>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub fast_model: Option<String>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub vision_model: Option<String>,
463 #[serde(skip_serializing_if = "Option::is_none")]
465 pub max_tokens: Option<u32>,
466 #[serde(skip_serializing_if = "Option::is_none")]
468 pub reasoning_effort: Option<ReasoningEffort>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub request_overrides: Option<RequestOverridesConfig>,
472
473 #[serde(default, flatten)]
475 pub extra: BTreeMap<String, Value>,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct GeminiConfig {
490 #[serde(default, skip_serializing)]
494 pub api_key: String,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub api_key_encrypted: Option<String>,
498 #[serde(skip_serializing_if = "Option::is_none")]
500 pub base_url: Option<String>,
501 #[serde(skip_serializing_if = "Option::is_none")]
503 pub model: Option<String>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub fast_model: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub vision_model: Option<String>,
512 #[serde(skip_serializing_if = "Option::is_none")]
514 pub reasoning_effort: Option<ReasoningEffort>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub request_overrides: Option<RequestOverridesConfig>,
518
519 #[serde(default, flatten)]
521 pub extra: BTreeMap<String, Value>,
522}
523
524#[derive(Debug, Clone, Default, Serialize, Deserialize)]
536pub struct CopilotConfig {
537 #[serde(default)]
539 pub enabled: bool,
540 #[serde(default)]
542 pub headless_auth: bool,
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub model: Option<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub fast_model: Option<String>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub vision_model: Option<String>,
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub reasoning_effort: Option<ReasoningEffort>,
557
558 #[serde(default, skip_serializing_if = "Vec::is_empty")]
567 pub responses_only_models: Vec<String>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub request_overrides: Option<RequestOverridesConfig>,
571
572 #[serde(default, flatten)]
574 pub extra: BTreeMap<String, Value>,
575}
576
577fn default_provider() -> String {
579 "anthropic".to_string()
580}
581
582fn default_port() -> u16 {
584 9562
585}
586
587fn default_bind() -> String {
589 "127.0.0.1".to_string()
590}
591
592fn default_workers() -> usize {
594 10
595}
596
597fn default_data_dir() -> PathBuf {
599 super::paths::bamboo_dir()
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct ServerConfig {
605 #[serde(default = "default_port")]
607 pub port: u16,
608
609 #[serde(default = "default_bind")]
611 pub bind: String,
612
613 pub static_dir: Option<PathBuf>,
615
616 #[serde(default = "default_workers")]
618 pub workers: usize,
619
620 #[serde(default, flatten)]
622 pub extra: BTreeMap<String, Value>,
623}
624
625impl Default for ServerConfig {
626 fn default() -> Self {
627 Self {
628 port: default_port(),
629 bind: default_bind(),
630 static_dir: None,
631 workers: default_workers(),
632 extra: BTreeMap::new(),
633 }
634 }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct ProxyAuth {
640 pub username: String,
642 pub password: String,
644}
645
646fn parse_bool_env(value: &str) -> bool {
650 matches!(
651 value.trim().to_ascii_lowercase().as_str(),
652 "1" | "true" | "yes" | "y" | "on"
653 )
654}
655
656impl Default for Config {
657 fn default() -> Self {
658 Self::new()
659 }
660}
661
662static ENV_VARS_CACHE: std::sync::LazyLock<RwLock<HashMap<String, String>>> =
666 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
667
668impl Config {
669 pub fn new() -> Self {
684 Self::from_data_dir(None)
685 }
686
687 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
693 let data_dir = data_dir
695 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
696 .unwrap_or_else(default_data_dir);
697
698 let config_path = data_dir.join("config.json");
699
700 let mut config = if config_path.exists() {
701 if let Ok(content) = std::fs::read_to_string(&config_path) {
702 serde_json::from_str::<Config>(&content)
703 .map(|mut config| {
704 config.hydrate_proxy_auth_from_encrypted();
705 config.hydrate_provider_api_keys_from_encrypted();
706 config.hydrate_mcp_secrets_from_encrypted();
707 config.hydrate_env_vars_from_encrypted();
708 config.normalize_tool_settings();
709 config
710 })
711 .unwrap_or_else(|e| {
712 tracing::warn!("Failed to parse config.json ({}), using defaults", e);
713 Self::create_default()
714 })
715 } else {
716 Self::create_default()
717 }
718 } else {
719 Self::create_default()
720 };
721
722 config.hydrate_proxy_auth_from_encrypted();
724 config.hydrate_provider_api_keys_from_encrypted();
726 config.hydrate_mcp_secrets_from_encrypted();
728 config.hydrate_env_vars_from_encrypted();
730 config.normalize_tool_settings();
731
732 config.extra.remove("data_dir");
735
736 if let Ok(port) = std::env::var("BAMBOO_PORT") {
738 if let Ok(port) = port.parse() {
739 config.server.port = port;
740 }
741 }
742
743 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
744 config.server.bind = bind;
745 }
746
747 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
749 config.provider = provider;
750 }
751
752 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
753 config.headless_auth = parse_bool_env(&headless);
754 }
755
756 config.publish_env_vars();
758
759 config
760 }
761
762 pub fn get_model(&self) -> Option<String> {
767 match self.provider.as_str() {
768 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
769 "anthropic" => self
770 .providers
771 .anthropic
772 .as_ref()
773 .and_then(|c| c.model.clone()),
774 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
775 "copilot" => Some(
776 self.providers
777 .copilot
778 .as_ref()
779 .and_then(|c| c.model.clone())
780 .unwrap_or_else(|| "gpt-4o".to_string()),
781 ),
782 _ => None,
783 }
784 }
785
786 pub fn get_fast_model(&self) -> Option<String> {
791 let fast = match self.provider.as_str() {
792 "openai" => self
793 .providers
794 .openai
795 .as_ref()
796 .and_then(|c| c.fast_model.clone()),
797 "anthropic" => self
798 .providers
799 .anthropic
800 .as_ref()
801 .and_then(|c| c.fast_model.clone()),
802 "gemini" => self
803 .providers
804 .gemini
805 .as_ref()
806 .and_then(|c| c.fast_model.clone()),
807 "copilot" => self
808 .providers
809 .copilot
810 .as_ref()
811 .and_then(|c| c.fast_model.clone()),
812 _ => None,
813 };
814 fast.or_else(|| self.get_model())
815 }
816
817 pub fn get_vision_model(&self) -> Option<String> {
822 let vision = match self.provider.as_str() {
823 "openai" => self
824 .providers
825 .openai
826 .as_ref()
827 .and_then(|c| c.vision_model.clone()),
828 "anthropic" => self
829 .providers
830 .anthropic
831 .as_ref()
832 .and_then(|c| c.vision_model.clone()),
833 "gemini" => self
834 .providers
835 .gemini
836 .as_ref()
837 .and_then(|c| c.vision_model.clone()),
838 "copilot" => self
839 .providers
840 .copilot
841 .as_ref()
842 .and_then(|c| c.vision_model.clone()),
843 _ => None,
844 };
845 vision.or_else(|| self.get_model())
846 }
847
848 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
850 match self.provider.as_str() {
851 "openai" => self
852 .providers
853 .openai
854 .as_ref()
855 .and_then(|c| c.reasoning_effort),
856 "anthropic" => self
857 .providers
858 .anthropic
859 .as_ref()
860 .and_then(|c| c.reasoning_effort),
861 "gemini" => self
862 .providers
863 .gemini
864 .as_ref()
865 .and_then(|c| c.reasoning_effort),
866 "copilot" => self
867 .providers
868 .copilot
869 .as_ref()
870 .and_then(|c| c.reasoning_effort),
871 _ => None,
872 }
873 }
874
875 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
877 self.tools
878 .disabled
879 .iter()
880 .map(|name| name.trim())
881 .filter(|name| !name.is_empty())
882 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
883 .collect()
884 }
885
886 pub fn normalize_tool_settings(&mut self) {
888 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
889 }
890
891 pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
897 if self.proxy_auth.is_some() {
898 return;
899 }
900
901 if self
908 .proxy_auth_encrypted
909 .as_deref()
910 .map(|s| s.trim().is_empty())
911 .unwrap_or(true)
912 {
913 let legacy = self
914 .extra
915 .get("https_proxy_auth_encrypted")
916 .and_then(|v| v.as_str())
917 .or_else(|| {
918 self.extra
919 .get("http_proxy_auth_encrypted")
920 .and_then(|v| v.as_str())
921 })
922 .map(|s| s.trim())
923 .filter(|s| !s.is_empty())
924 .map(|s| s.to_string());
925
926 if let Some(legacy) = legacy {
927 self.proxy_auth_encrypted = Some(legacy);
928 }
929 }
930
931 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
932 return;
933 };
934
935 match crate::core::encryption::decrypt(encrypted) {
936 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
937 Ok(auth) => {
938 self.proxy_auth = Some(auth);
939 self.extra.remove("http_proxy_auth_encrypted");
942 self.extra.remove("https_proxy_auth_encrypted");
943 }
944 Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
945 },
946 Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
947 }
948 }
949
950 pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
955 let Some(auth) = self.proxy_auth.as_ref() else {
959 self.proxy_auth_encrypted = None;
960 return Ok(());
961 };
962
963 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
964 let encrypted =
965 crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
966 self.proxy_auth_encrypted = Some(encrypted);
967 Ok(())
968 }
969
970 pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
971 if let Some(openai) = self.providers.openai.as_mut() {
972 if openai.api_key.trim().is_empty() {
973 if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
974 match crate::core::encryption::decrypt(encrypted) {
975 Ok(value) => openai.api_key = value,
976 Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
977 }
978 }
979 }
980 }
981
982 if let Some(anthropic) = self.providers.anthropic.as_mut() {
983 if anthropic.api_key.trim().is_empty() {
984 if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
985 match crate::core::encryption::decrypt(encrypted) {
986 Ok(value) => anthropic.api_key = value,
987 Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
988 }
989 }
990 }
991 }
992
993 if let Some(gemini) = self.providers.gemini.as_mut() {
994 if gemini.api_key.trim().is_empty() {
995 if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
996 match crate::core::encryption::decrypt(encrypted) {
997 Ok(value) => gemini.api_key = value,
998 Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
999 }
1000 }
1001 }
1002 }
1003 }
1004
1005 pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
1006 if let Some(openai) = self.providers.openai.as_mut() {
1007 let api_key = openai.api_key.trim();
1008 openai.api_key_encrypted = if api_key.is_empty() {
1009 None
1010 } else {
1011 Some(
1012 crate::core::encryption::encrypt(api_key)
1013 .context("Failed to encrypt OpenAI api_key")?,
1014 )
1015 };
1016 }
1017
1018 if let Some(anthropic) = self.providers.anthropic.as_mut() {
1019 let api_key = anthropic.api_key.trim();
1020 anthropic.api_key_encrypted = if api_key.is_empty() {
1021 None
1022 } else {
1023 Some(
1024 crate::core::encryption::encrypt(api_key)
1025 .context("Failed to encrypt Anthropic api_key")?,
1026 )
1027 };
1028 }
1029
1030 if let Some(gemini) = self.providers.gemini.as_mut() {
1031 let api_key = gemini.api_key.trim();
1032 gemini.api_key_encrypted = if api_key.is_empty() {
1033 None
1034 } else {
1035 Some(
1036 crate::core::encryption::encrypt(api_key)
1037 .context("Failed to encrypt Gemini api_key")?,
1038 )
1039 };
1040 }
1041
1042 Ok(())
1043 }
1044
1045 pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
1046 for server in self.mcp.servers.iter_mut() {
1047 match &mut server.transport {
1048 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1049 if stdio.env_encrypted.is_empty() {
1050 continue;
1051 }
1052
1053 for (key, encrypted) in stdio.env_encrypted.clone() {
1055 let should_hydrate = stdio
1056 .env
1057 .get(&key)
1058 .map(|v| v.trim().is_empty())
1059 .unwrap_or(true);
1060 if !should_hydrate {
1061 continue;
1062 }
1063
1064 match crate::core::encryption::decrypt(&encrypted) {
1065 Ok(value) => {
1066 stdio.env.insert(key, value);
1067 }
1068 Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
1069 }
1070 }
1071 }
1072 crate::agent::mcp::TransportConfig::Sse(sse) => {
1073 for header in sse.headers.iter_mut() {
1074 if !header.value.trim().is_empty() {
1075 continue;
1076 }
1077 let Some(encrypted) = header.value_encrypted.as_deref() else {
1078 continue;
1079 };
1080 match crate::core::encryption::decrypt(encrypted) {
1081 Ok(value) => header.value = value,
1082 Err(e) => {
1083 tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
1084 }
1085 }
1086 }
1087 }
1088 }
1089 }
1090 }
1091
1092 pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
1093 for server in self.mcp.servers.iter_mut() {
1094 match &mut server.transport {
1095 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1096 stdio.env_encrypted.clear();
1097 for (key, value) in &stdio.env {
1098 let encrypted =
1099 crate::core::encryption::encrypt(value).with_context(|| {
1100 format!("Failed to encrypt MCP stdio env var '{key}'")
1101 })?;
1102 stdio.env_encrypted.insert(key.clone(), encrypted);
1103 }
1104 }
1105 crate::agent::mcp::TransportConfig::Sse(sse) => {
1106 for header in sse.headers.iter_mut() {
1107 let configured = !header.value.trim().is_empty();
1108 header.value_encrypted = if !configured {
1109 None
1110 } else {
1111 Some(
1112 crate::core::encryption::encrypt(&header.value).with_context(
1113 || {
1114 format!(
1115 "Failed to encrypt MCP SSE header '{}'",
1116 header.name
1117 )
1118 },
1119 )?,
1120 )
1121 };
1122 }
1123 }
1124 }
1125 }
1126
1127 Ok(())
1128 }
1129
1130 pub fn hydrate_env_vars_from_encrypted(&mut self) {
1134 for entry in &mut self.env_vars {
1135 if !entry.secret {
1136 continue;
1137 }
1138 if !entry.value.trim().is_empty() {
1139 continue;
1141 }
1142 let Some(encrypted) = &entry.value_encrypted else {
1143 continue;
1144 };
1145 match crate::core::encryption::decrypt(encrypted) {
1146 Ok(value) => entry.value = value,
1147 Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
1148 }
1149 }
1150 }
1151
1152 pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
1154 for entry in &mut self.env_vars {
1155 if entry.secret && !entry.value.trim().is_empty() {
1156 entry.value_encrypted = Some(
1157 crate::core::encryption::encrypt(&entry.value)
1158 .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
1159 );
1160 } else if !entry.secret {
1161 entry.value_encrypted = None;
1162 }
1163 }
1164 Ok(())
1165 }
1166
1167 pub fn sanitize_env_vars_for_disk(&mut self) {
1169 for entry in &mut self.env_vars {
1170 if entry.secret {
1171 entry.value = String::new();
1172 }
1173 }
1174 }
1175
1176 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
1178 self.env_vars
1179 .iter()
1180 .filter(|e| !e.value.trim().is_empty())
1181 .map(|e| (e.name.clone(), e.value.clone()))
1182 .collect()
1183 }
1184
1185 pub fn publish_env_vars(&self) {
1187 let map = self.env_vars_as_map();
1188 if let Ok(mut guard) = ENV_VARS_CACHE.write() {
1189 *guard = map;
1190 }
1191 }
1192
1193 pub fn current_env_vars() -> HashMap<String, String> {
1195 ENV_VARS_CACHE
1196 .read()
1197 .map(|guard| guard.clone())
1198 .unwrap_or_default()
1199 }
1200
1201 fn create_default() -> Self {
1203 Config {
1204 http_proxy: String::new(),
1205 https_proxy: String::new(),
1206 proxy_auth: None,
1207 proxy_auth_encrypted: None,
1208 headless_auth: false,
1209 provider: default_provider(),
1210 providers: ProviderConfigs::default(),
1211 server: ServerConfig::default(),
1212 keyword_masking: KeywordMaskingConfig::default(),
1213 anthropic_model_mapping: AnthropicModelMapping::default(),
1214 gemini_model_mapping: GeminiModelMapping::default(),
1215 hooks: HooksConfig::default(),
1216 tools: ToolsConfig::default(),
1217 env_vars: Vec::new(),
1218 mcp: crate::agent::mcp::McpConfig::default(),
1219 extra: BTreeMap::new(),
1220 }
1221 }
1222
1223 pub fn server_addr(&self) -> String {
1225 format!("{}:{}", self.server.bind, self.server.port)
1226 }
1227
1228 pub fn save(&self) -> Result<()> {
1230 self.save_to_dir(default_data_dir())
1231 }
1232
1233 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
1237 let path = data_dir.join("config.json");
1238
1239 if let Some(parent) = path.parent() {
1240 std::fs::create_dir_all(parent)
1241 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
1242 }
1243
1244 let mut to_save = self.clone();
1245 to_save.extra.remove("data_dir");
1247 to_save.extra.remove("model");
1249 to_save.refresh_proxy_auth_encrypted()?;
1250 to_save.refresh_provider_api_keys_encrypted()?;
1251 to_save.refresh_env_vars_encrypted()?;
1252 to_save.sanitize_env_vars_for_disk();
1253 to_save.normalize_tool_settings();
1254 let content =
1255 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
1256 write_atomic(&path, content.as_bytes())
1257 .with_context(|| format!("Failed to write config file: {:?}", path))?;
1258
1259 Ok(())
1260 }
1261}
1262
1263fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
1264 let Some(parent) = path.parent() else {
1265 return std::fs::write(path, content);
1266 };
1267
1268 std::fs::create_dir_all(parent)?;
1269
1270 let file_name = path
1273 .file_name()
1274 .and_then(|s| s.to_str())
1275 .unwrap_or("config.json");
1276 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1277 let tmp_path = parent.join(tmp_name);
1278
1279 {
1280 let mut file = std::fs::File::create(&tmp_path)?;
1281 file.write_all(content)?;
1282 file.sync_all()?;
1283 }
1284
1285 std::fs::rename(&tmp_path, path)?;
1286 Ok(())
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use std::ffi::OsString;
1293 use std::path::PathBuf;
1294 use std::sync::{Mutex, OnceLock};
1295 use std::time::{SystemTime, UNIX_EPOCH};
1296
1297 struct EnvVarGuard {
1298 key: &'static str,
1299 previous: Option<OsString>,
1300 }
1301
1302 impl EnvVarGuard {
1303 fn set(key: &'static str, value: &str) -> Self {
1304 let previous = std::env::var_os(key);
1305 std::env::set_var(key, value);
1306 Self { key, previous }
1307 }
1308
1309 fn unset(key: &'static str) -> Self {
1310 let previous = std::env::var_os(key);
1311 std::env::remove_var(key);
1312 Self { key, previous }
1313 }
1314 }
1315
1316 impl Drop for EnvVarGuard {
1317 fn drop(&mut self) {
1318 match &self.previous {
1319 Some(value) => std::env::set_var(self.key, value),
1320 None => std::env::remove_var(self.key),
1321 }
1322 }
1323 }
1324
1325 struct TempHome {
1326 path: PathBuf,
1327 }
1328
1329 impl TempHome {
1330 fn new() -> Self {
1331 let nanos = SystemTime::now()
1332 .duration_since(UNIX_EPOCH)
1333 .expect("clock should be after unix epoch")
1334 .as_nanos();
1335 let path = std::env::temp_dir().join(format!(
1336 "chat-core-config-test-{}-{}",
1337 std::process::id(),
1338 nanos
1339 ));
1340 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1341 Self { path }
1342 }
1343
1344 fn set_config_json(&self, content: &str) {
1345 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1348 std::fs::write(self.path.join("config.json"), content)
1349 .expect("failed to write config.json");
1350 }
1351 }
1352
1353 impl Drop for TempHome {
1354 fn drop(&mut self) {
1355 let _ = std::fs::remove_dir_all(&self.path);
1356 }
1357 }
1358
1359 fn env_lock() -> &'static Mutex<()> {
1360 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1361 LOCK.get_or_init(|| Mutex::new(()))
1362 }
1363
1364 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1366 env_lock().lock().unwrap_or_else(|poisoned| {
1367 poisoned.into_inner()
1369 })
1370 }
1371
1372 #[test]
1373 fn parse_bool_env_true_values() {
1374 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1375 assert!(parse_bool_env(value), "value {value:?} should be true");
1376 }
1377 }
1378
1379 #[test]
1380 fn parse_bool_env_false_values() {
1381 for value in ["0", "false", "no", "off", "", " "] {
1382 assert!(!parse_bool_env(value), "value {value:?} should be false");
1383 }
1384 }
1385
1386 #[test]
1387 fn config_new_ignores_http_proxy_env_vars() {
1388 let _lock = env_lock_acquire();
1389 let temp_home = TempHome::new();
1390 temp_home.set_config_json(
1391 r#"{
1392 "http_proxy": "",
1393 "https_proxy": ""
1394}"#,
1395 );
1396
1397 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1398 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1399
1400 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1401
1402 assert!(
1403 config.http_proxy.is_empty(),
1404 "config should ignore HTTP_PROXY env var"
1405 );
1406 assert!(
1407 config.https_proxy.is_empty(),
1408 "config should ignore HTTPS_PROXY env var"
1409 );
1410 }
1411
1412 #[test]
1413 fn config_new_loads_config_when_proxy_fields_omitted() {
1414 let _lock = env_lock_acquire();
1415 let temp_home = TempHome::new();
1416 temp_home.set_config_json(
1417 r#"{
1418 "provider": "openai",
1419 "providers": {
1420 "openai": {
1421 "api_key": "sk-test",
1422 "model": "gpt-4o"
1423 }
1424 }
1425}"#,
1426 );
1427
1428 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1429 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1430
1431 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1432
1433 assert_eq!(
1434 config
1435 .providers
1436 .openai
1437 .as_ref()
1438 .and_then(|c| c.model.as_deref()),
1439 Some("gpt-4o"),
1440 "config should load provider model from config file even when proxy fields are omitted"
1441 );
1442 assert!(config.http_proxy.is_empty());
1443 assert!(config.https_proxy.is_empty());
1444 }
1445
1446 #[test]
1447 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
1448 let _lock = env_lock_acquire();
1449 let temp_home = TempHome::new();
1450 temp_home.set_config_json(
1451 r#"{
1452 "provider": "openai",
1453 "providers": {
1454 "openai": {
1455 "api_key": "sk-test",
1456 "model": "gpt-4o"
1457 }
1458 }
1459}"#,
1460 );
1461
1462 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1463 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1464
1465 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1466
1467 assert_eq!(
1468 config
1469 .providers
1470 .openai
1471 .as_ref()
1472 .and_then(|c| c.model.as_deref()),
1473 Some("gpt-4o")
1474 );
1475 assert!(
1476 config.http_proxy.is_empty(),
1477 "config should keep http_proxy empty when field is omitted"
1478 );
1479 assert!(
1480 config.https_proxy.is_empty(),
1481 "config should keep https_proxy empty when field is omitted"
1482 );
1483 }
1484
1485 #[test]
1486 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
1487 let mut config = Config::default();
1488 config.tools.disabled = vec![
1489 " read_file ".to_string(),
1490 "".to_string(),
1491 "read_file".to_string(),
1492 "bash".to_string(),
1493 "default::getCurrentDir".to_string(),
1494 ];
1495
1496 config.normalize_tool_settings();
1497
1498 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
1499 }
1500
1501 #[test]
1502 fn config_load_reads_disabled_tools_as_canonical_names() {
1503 let _lock = env_lock_acquire();
1504 let temp_home = TempHome::new();
1505 temp_home.set_config_json(
1506 r#"{
1507 "tools": {
1508 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
1509 }
1510}"#,
1511 );
1512
1513 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1514 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
1515 assert!(config.disabled_tool_names().contains("Bash"));
1516 assert!(config.disabled_tool_names().contains("Read"));
1517 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
1518 }
1519
1520 #[test]
1521 fn test_server_config_defaults() {
1522 let _lock = env_lock_acquire();
1523 let temp_home = TempHome::new();
1524
1525 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1526 assert_eq!(config.server.port, 9562);
1527 assert_eq!(config.server.bind, "127.0.0.1");
1528 assert_eq!(config.server.workers, 10);
1529 assert!(config.server.static_dir.is_none());
1530 }
1531
1532 #[test]
1533 fn test_server_addr() {
1534 let mut config = Config::default();
1535 config.server.port = 9000;
1536 config.server.bind = "0.0.0.0".to_string();
1537 assert_eq!(config.server_addr(), "0.0.0.0:9000");
1538 }
1539
1540 #[test]
1541 fn test_env_var_overrides() {
1542 let _lock = env_lock_acquire();
1543 let temp_home = TempHome::new();
1544
1545 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1546 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1547 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1548
1549 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1550 assert_eq!(config.server.port, 9999);
1551 assert_eq!(config.server.bind, "192.168.1.1");
1552 assert_eq!(config.provider, "openai");
1553 }
1554
1555 #[test]
1556 fn test_config_save_and_load() {
1557 let _lock = env_lock_acquire();
1558 let temp_home = TempHome::new();
1559
1560 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1561 config.server.port = 9000;
1562 config.server.bind = "0.0.0.0".to_string();
1563 config.provider = "anthropic".to_string();
1564
1565 config
1567 .save_to_dir(temp_home.path.clone())
1568 .expect("Failed to save config");
1569
1570 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1572
1573 assert_eq!(loaded.server.port, 9000);
1575 assert_eq!(loaded.server.bind, "0.0.0.0");
1576 assert_eq!(loaded.provider, "anthropic");
1577 }
1578
1579 #[test]
1580 fn config_decrypts_proxy_auth_from_encrypted_field() {
1581 let _lock = env_lock_acquire();
1582 let temp_home = TempHome::new();
1583
1584 let key_guard = crate::core::encryption::set_test_encryption_key([
1586 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1587 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1588 0x1c, 0x1d, 0x1e, 0x1f,
1589 ]);
1590
1591 let auth = ProxyAuth {
1592 username: "user".to_string(),
1593 password: "pass".to_string(),
1594 };
1595 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1596 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1597
1598 temp_home.set_config_json(&format!(
1599 r#"{{
1600 "http_proxy": "http://proxy.example.com:8080",
1601 "proxy_auth_encrypted": "{encrypted}"
1602}}"#
1603 ));
1604 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1605 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1606 assert_eq!(loaded_auth.username, "user");
1607 assert_eq!(loaded_auth.password, "pass");
1608 drop(key_guard);
1609 }
1610
1611 #[test]
1612 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
1613 let _lock = env_lock_acquire();
1614 let temp_home = TempHome::new();
1615
1616 let key_guard = crate::core::encryption::set_test_encryption_key([
1618 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1619 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1620 0x1c, 0x1d, 0x1e, 0x1f,
1621 ]);
1622
1623 let auth = ProxyAuth {
1624 username: "user".to_string(),
1625 password: "pass".to_string(),
1626 };
1627 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1628 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1629
1630 temp_home.set_config_json(&format!(
1632 r#"{{
1633 "http_proxy": "http://proxy.example.com:8080",
1634 "http_proxy_auth_encrypted": "{encrypted}",
1635 "https_proxy_auth_encrypted": "{encrypted}"
1636}}"#
1637 ));
1638
1639 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1640 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1641 assert_eq!(loaded_auth.username, "user");
1642 assert_eq!(loaded_auth.password, "pass");
1643 drop(key_guard);
1644 }
1645
1646 #[test]
1647 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1648 let _lock = env_lock_acquire();
1649 let temp_home = TempHome::new();
1650
1651 let key_guard = crate::core::encryption::set_test_encryption_key([
1653 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1654 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1655 0x1c, 0x1d, 0x1e, 0x1f,
1656 ]);
1657
1658 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1659 config.proxy_auth = Some(ProxyAuth {
1660 username: "user".to_string(),
1661 password: "pass".to_string(),
1662 });
1663 config
1664 .save_to_dir(temp_home.path.clone())
1665 .expect("save should encrypt proxy auth");
1666
1667 let content =
1668 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1669 assert!(
1670 content.contains("proxy_auth_encrypted"),
1671 "config.json should store encrypted proxy auth"
1672 );
1673 assert!(
1674 !content.contains("\"proxy_auth\""),
1675 "config.json should not store plaintext proxy_auth"
1676 );
1677
1678 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1679 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1680 assert_eq!(loaded_auth.username, "user");
1681 assert_eq!(loaded_auth.password, "pass");
1682 drop(key_guard);
1683 }
1684
1685 #[test]
1686 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
1687 let _lock = env_lock_acquire();
1688 let temp_home = TempHome::new();
1689
1690 let key_guard = crate::core::encryption::set_test_encryption_key([
1692 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1693 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1694 0x1c, 0x1d, 0x1e, 0x1f,
1695 ]);
1696
1697 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1698 config.provider = "openai".to_string();
1699 config.providers.openai = Some(OpenAIConfig {
1700 api_key: "sk-test-provider-key".to_string(),
1701 api_key_encrypted: None,
1702 base_url: None,
1703 model: None,
1704 fast_model: None,
1705 vision_model: None,
1706 reasoning_effort: None,
1707 responses_only_models: vec![],
1708 request_overrides: None,
1709 extra: Default::default(),
1710 });
1711
1712 config
1713 .save_to_dir(temp_home.path.clone())
1714 .expect("save should encrypt provider api keys");
1715
1716 let content =
1717 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1718 assert!(
1719 content.contains("\"api_key_encrypted\""),
1720 "config.json should store encrypted provider keys"
1721 );
1722 assert!(
1723 !content.contains("\"api_key\""),
1724 "config.json should not store plaintext provider keys"
1725 );
1726
1727 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1728 let openai = loaded
1729 .providers
1730 .openai
1731 .expect("openai config should be present");
1732 assert_eq!(openai.api_key, "sk-test-provider-key");
1733
1734 drop(key_guard);
1735 }
1736
1737 #[test]
1738 fn config_save_persists_mcp_servers_in_mainstream_format() {
1739 let _lock = env_lock_acquire();
1740 let temp_home = TempHome::new();
1741
1742 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1743
1744 let mut env = std::collections::HashMap::new();
1745 env.insert("TOKEN".to_string(), "supersecret".to_string());
1746
1747 config.mcp.servers = vec![
1748 crate::agent::mcp::McpServerConfig {
1749 id: "stdio-secret".to_string(),
1750 name: None,
1751 enabled: true,
1752 transport: crate::agent::mcp::TransportConfig::Stdio(
1753 crate::agent::mcp::StdioConfig {
1754 command: "echo".to_string(),
1755 args: vec![],
1756 cwd: None,
1757 env,
1758 env_encrypted: std::collections::HashMap::new(),
1759 startup_timeout_ms: 5000,
1760 },
1761 ),
1762 request_timeout_ms: 5000,
1763 healthcheck_interval_ms: 1000,
1764 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1765 allowed_tools: vec![],
1766 denied_tools: vec![],
1767 },
1768 crate::agent::mcp::McpServerConfig {
1769 id: "sse-secret".to_string(),
1770 name: None,
1771 enabled: true,
1772 transport: crate::agent::mcp::TransportConfig::Sse(crate::agent::mcp::SseConfig {
1773 url: "http://localhost:8080/sse".to_string(),
1774 headers: vec![crate::agent::mcp::HeaderConfig {
1775 name: "Authorization".to_string(),
1776 value: "Bearer token123".to_string(),
1777 value_encrypted: None,
1778 }],
1779 connect_timeout_ms: 5000,
1780 }),
1781 request_timeout_ms: 5000,
1782 healthcheck_interval_ms: 1000,
1783 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1784 allowed_tools: vec![],
1785 denied_tools: vec![],
1786 },
1787 ];
1788
1789 config
1790 .save_to_dir(temp_home.path.clone())
1791 .expect("save should persist MCP servers");
1792
1793 let content =
1794 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1795 assert!(
1796 content.contains("\"mcpServers\""),
1797 "config.json should store MCP servers under the mainstream 'mcpServers' key"
1798 );
1799 assert!(
1800 content.contains("supersecret"),
1801 "config.json should persist MCP stdio env in mainstream format"
1802 );
1803 assert!(
1804 content.contains("Bearer token123"),
1805 "config.json should persist MCP SSE headers in mainstream format"
1806 );
1807 assert!(
1808 !content.contains("\"env_encrypted\""),
1809 "config.json should not persist legacy env_encrypted fields"
1810 );
1811 assert!(
1812 !content.contains("\"value_encrypted\""),
1813 "config.json should not persist legacy value_encrypted fields"
1814 );
1815
1816 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1817 let stdio = loaded
1818 .mcp
1819 .servers
1820 .iter()
1821 .find(|s| s.id == "stdio-secret")
1822 .expect("stdio server should exist");
1823 match &stdio.transport {
1824 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1825 assert_eq!(
1826 stdio.env.get("TOKEN").map(|s| s.as_str()),
1827 Some("supersecret")
1828 );
1829 }
1830 _ => panic!("Expected stdio transport"),
1831 }
1832
1833 let sse = loaded
1834 .mcp
1835 .servers
1836 .iter()
1837 .find(|s| s.id == "sse-secret")
1838 .expect("sse server should exist");
1839 match &sse.transport {
1840 crate::agent::mcp::TransportConfig::Sse(sse) => {
1841 assert_eq!(sse.headers[0].value, "Bearer token123");
1842 }
1843 _ => panic!("Expected SSE transport"),
1844 }
1845 }
1846
1847 #[test]
1850 fn env_vars_as_map_includes_only_non_empty_values() {
1851 let mut config = Config::default();
1852 config.env_vars = vec![
1853 EnvVarEntry {
1854 name: "A".to_string(),
1855 value: "val_a".to_string(),
1856 secret: false,
1857 value_encrypted: None,
1858 description: None,
1859 },
1860 EnvVarEntry {
1861 name: "B".to_string(),
1862 value: "".to_string(), secret: true,
1864 value_encrypted: None,
1865 description: None,
1866 },
1867 EnvVarEntry {
1868 name: "C".to_string(),
1869 value: " ".to_string(), secret: false,
1871 value_encrypted: None,
1872 description: None,
1873 },
1874 EnvVarEntry {
1875 name: "D".to_string(),
1876 value: "val_d".to_string(),
1877 secret: true,
1878 value_encrypted: Some("enc".to_string()),
1879 description: Some("desc".to_string()),
1880 },
1881 ];
1882
1883 let map = config.env_vars_as_map();
1884 assert_eq!(map.len(), 2);
1885 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
1886 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
1887 assert!(!map.contains_key("B"));
1888 assert!(!map.contains_key("C"));
1889 }
1890
1891 #[test]
1892 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
1893 let mut config = Config::default();
1894 config.env_vars = vec![
1895 EnvVarEntry {
1896 name: "PLAIN".to_string(),
1897 value: "visible".to_string(),
1898 secret: false,
1899 value_encrypted: None,
1900 description: None,
1901 },
1902 EnvVarEntry {
1903 name: "SECRET".to_string(),
1904 value: "hidden_value".to_string(),
1905 secret: true,
1906 value_encrypted: Some("enc_data".to_string()),
1907 description: None,
1908 },
1909 ];
1910
1911 config.sanitize_env_vars_for_disk();
1912
1913 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
1916
1917 #[test]
1918 fn sanitize_env_vars_for_disk_preserves_encrypted() {
1919 let mut config = Config::default();
1920 config.env_vars = vec![
1921 EnvVarEntry {
1922 name: "OPEN".to_string(),
1923 value: "val".to_string(),
1924 secret: false,
1925 value_encrypted: None,
1926 description: None,
1927 },
1928 EnvVarEntry {
1929 name: "HIDDEN".to_string(),
1930 value: "real_secret".to_string(),
1931 secret: true,
1932 value_encrypted: Some("enc".to_string()),
1933 description: None,
1934 },
1935 ];
1936
1937 config.sanitize_env_vars_for_disk();
1938
1939 assert_eq!(config.env_vars[0].value, "val");
1941 assert_eq!(config.env_vars[1].value, "");
1943 assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
1944 }
1945
1946 #[test]
1947 fn refresh_env_vars_encrypted_round_trip() {
1948 let mut config = Config::default();
1949 config.env_vars = vec![
1950 EnvVarEntry {
1951 name: "TOKEN".to_string(),
1952 value: "my-secret-token".to_string(),
1953 secret: true,
1954 value_encrypted: None,
1955 description: Some("A token".to_string()),
1956 },
1957 EnvVarEntry {
1958 name: "PLAIN_VAR".to_string(),
1959 value: "hello".to_string(),
1960 secret: false,
1961 value_encrypted: None,
1962 description: None,
1963 },
1964 ];
1965
1966 config
1968 .refresh_env_vars_encrypted()
1969 .expect("encryption should succeed");
1970
1971 assert!(config.env_vars[0].value_encrypted.is_some());
1973 assert!(config.env_vars[1].value_encrypted.is_none());
1975
1976 let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
1978 assert_ne!(encrypted, "my-secret-token"); config.sanitize_env_vars_for_disk();
1982 assert_eq!(config.env_vars[0].value, "");
1983
1984 config.hydrate_env_vars_from_encrypted();
1986 assert_eq!(config.env_vars[0].value, "my-secret-token");
1987 assert_eq!(config.env_vars[1].value, "hello"); }
1989
1990 #[test]
1991 fn publish_and_current_env_vars_round_trip() {
1992 let mut config = Config::default();
1993 config.env_vars = vec![EnvVarEntry {
1994 name: "TEST_PUBLISH".to_string(),
1995 value: "pub_value".to_string(),
1996 secret: false,
1997 value_encrypted: None,
1998 description: None,
1999 }];
2000
2001 config.publish_env_vars();
2002 let map = Config::current_env_vars();
2003 assert_eq!(map.get("TEST_PUBLISH"), Some(&"pub_value".to_string()));
2004 }
2005
2006 #[test]
2007 fn hydrate_skips_non_secret_entries() {
2008 let mut config = Config::default();
2009 config.env_vars = vec![EnvVarEntry {
2010 name: "PLAIN".to_string(),
2011 value: "original".to_string(),
2012 secret: false,
2013 value_encrypted: Some("should-be-ignored".to_string()),
2014 description: None,
2015 }];
2016
2017 config.hydrate_env_vars_from_encrypted();
2018 assert_eq!(config.env_vars[0].value, "original");
2020 }
2021
2022 #[test]
2023 fn default_config_has_empty_env_vars() {
2024 let config = Config::default();
2025 assert!(config.env_vars.is_empty());
2026 }
2027
2028 #[test]
2029 fn serde_round_trip_with_env_vars() {
2030 let mut config = Config::default();
2031 config.env_vars = vec![
2032 EnvVarEntry {
2033 name: "KEY1".to_string(),
2034 value: "val1".to_string(),
2035 secret: false,
2036 value_encrypted: None,
2037 description: Some("First key".to_string()),
2038 },
2039 EnvVarEntry {
2040 name: "KEY2".to_string(),
2041 value: "".to_string(), secret: true,
2043 value_encrypted: Some("enc123".to_string()),
2044 description: None,
2045 },
2046 ];
2047
2048 let json = serde_json::to_string(&config).unwrap();
2049 let restored: Config = serde_json::from_str(&json).unwrap();
2050
2051 assert_eq!(restored.env_vars.len(), 2);
2052 assert_eq!(restored.env_vars[0].name, "KEY1");
2053 assert_eq!(restored.env_vars[0].value, "val1");
2054 assert!(!restored.env_vars[0].secret);
2055 assert_eq!(restored.env_vars[1].name, "KEY2");
2056 assert!(restored.env_vars[1].secret);
2057 assert_eq!(
2058 restored.env_vars[1].value_encrypted.as_deref(),
2059 Some("enc123")
2060 );
2061 }
2062}