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 = "SkillsConfig::is_empty")]
161 pub skills: SkillsConfig,
162
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub env_vars: Vec<EnvVarEntry>,
168
169 #[serde(default, rename = "mcpServers", alias = "mcp")]
175 pub mcp: crate::agent::mcp::McpConfig,
176
177 #[serde(default, flatten)]
183 pub extra: BTreeMap<String, Value>,
184}
185
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190pub struct ProviderConfigs {
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub openai: Option<OpenAIConfig>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub anthropic: Option<AnthropicConfig>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub gemini: Option<GeminiConfig>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub copilot: Option<CopilotConfig>,
203
204 #[serde(default, flatten)]
206 pub extra: BTreeMap<String, Value>,
207}
208
209#[derive(Debug, Clone, Default, Serialize, Deserialize)]
211pub struct HooksConfig {
212 #[serde(default)]
214 pub image_fallback: ImageFallbackHookConfig,
215}
216
217#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
224pub struct RequestOverridesConfig {
225 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
227 pub common: RequestScopeOverride,
228 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
230 pub endpoints: BTreeMap<String, RequestScopeOverride>,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub rules: Vec<ModelRequestRule>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238pub struct ModelRequestRule {
239 pub model_pattern: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub endpoint: Option<String>,
244 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
246 pub scope: RequestScopeOverride,
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
251pub struct RequestScopeOverride {
252 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
254 pub headers: BTreeMap<String, TemplateExpr>,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub body_patch: Vec<BodyPatch>,
258}
259
260impl RequestScopeOverride {
261 pub fn is_empty(&self) -> bool {
262 self.headers.is_empty() && self.body_patch.is_empty()
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268pub struct BodyPatch {
269 pub path: String,
271 #[serde(default)]
273 pub op: BodyPatchOp,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub value: Option<PatchValue>,
277}
278
279#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282pub enum BodyPatchOp {
283 #[default]
284 Set,
285 Remove,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
290#[serde(untagged)]
291pub enum PatchValue {
292 Template(TemplateExpr),
293 Json(Value),
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
298#[serde(untagged)]
299pub enum TemplateExpr {
300 Literal(String),
302 Structured(TemplateExprSpec),
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
308#[serde(tag = "type", rename_all = "snake_case")]
309pub enum TemplateExprSpec {
310 Literal { value: String },
312 EnvRef {
314 name: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 fallback: Option<String>,
317 },
318 Generated { generator: GeneratedValue },
320 Format { template: String },
322}
323
324#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
326#[serde(rename_all = "snake_case")]
327pub enum GeneratedValue {
328 Uuid,
329 UnixMs,
330}
331
332#[derive(Debug, Clone, Default, Serialize, Deserialize)]
334pub struct ToolsConfig {
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub disabled: Vec<String>,
338}
339
340impl ToolsConfig {
341 fn is_empty(&self) -> bool {
342 self.disabled.is_empty()
343 }
344}
345
346#[derive(Debug, Clone, Default, Serialize, Deserialize)]
348pub struct SkillsConfig {
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub disabled: Vec<String>,
352}
353
354impl SkillsConfig {
355 fn is_empty(&self) -> bool {
356 self.disabled.is_empty()
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct ImageFallbackHookConfig {
366 #[serde(default = "default_true_hooks")]
367 pub enabled: bool,
368
369 #[serde(default = "default_image_fallback_mode")]
371 pub mode: String,
372}
373
374impl Default for ImageFallbackHookConfig {
375 fn default() -> Self {
376 Self {
377 enabled: default_true_hooks(),
378 mode: default_image_fallback_mode(),
379 }
380 }
381}
382
383fn default_image_fallback_mode() -> String {
384 "placeholder".to_string()
385}
386
387fn default_true_hooks() -> bool {
388 false
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct OpenAIConfig {
406 #[serde(default, skip_serializing)]
410 pub api_key: String,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub api_key_encrypted: Option<String>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub base_url: Option<String>,
417 #[serde(skip_serializing_if = "Option::is_none")]
419 pub model: Option<String>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub fast_model: Option<String>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub vision_model: Option<String>,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub reasoning_effort: Option<ReasoningEffort>,
431
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub responses_only_models: Vec<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub request_overrides: Option<RequestOverridesConfig>,
443
444 #[serde(default, flatten)]
446 pub extra: BTreeMap<String, Value>,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct AnthropicConfig {
462 #[serde(default, skip_serializing)]
466 pub api_key: String,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub api_key_encrypted: Option<String>,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub base_url: Option<String>,
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub model: Option<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub fast_model: Option<String>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub vision_model: Option<String>,
484 #[serde(skip_serializing_if = "Option::is_none")]
486 pub max_tokens: Option<u32>,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub reasoning_effort: Option<ReasoningEffort>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub request_overrides: Option<RequestOverridesConfig>,
493
494 #[serde(default, flatten)]
496 pub extra: BTreeMap<String, Value>,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct GeminiConfig {
511 #[serde(default, skip_serializing)]
515 pub api_key: String,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub api_key_encrypted: Option<String>,
519 #[serde(skip_serializing_if = "Option::is_none")]
521 pub base_url: Option<String>,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 pub model: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub fast_model: Option<String>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub vision_model: Option<String>,
533 #[serde(skip_serializing_if = "Option::is_none")]
535 pub reasoning_effort: Option<ReasoningEffort>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub request_overrides: Option<RequestOverridesConfig>,
539
540 #[serde(default, flatten)]
542 pub extra: BTreeMap<String, Value>,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
557pub struct CopilotConfig {
558 #[serde(default)]
560 pub enabled: bool,
561 #[serde(default)]
563 pub headless_auth: bool,
564 #[serde(skip_serializing_if = "Option::is_none")]
566 pub model: Option<String>,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub fast_model: Option<String>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub vision_model: Option<String>,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub reasoning_effort: Option<ReasoningEffort>,
578
579 #[serde(default, skip_serializing_if = "Vec::is_empty")]
588 pub responses_only_models: Vec<String>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub request_overrides: Option<RequestOverridesConfig>,
592
593 #[serde(default, flatten)]
595 pub extra: BTreeMap<String, Value>,
596}
597
598fn default_provider() -> String {
600 "anthropic".to_string()
601}
602
603fn default_port() -> u16 {
605 9562
606}
607
608fn default_bind() -> String {
610 "127.0.0.1".to_string()
611}
612
613fn default_workers() -> usize {
615 10
616}
617
618fn default_data_dir() -> PathBuf {
620 super::paths::bamboo_dir()
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct ServerConfig {
626 #[serde(default = "default_port")]
628 pub port: u16,
629
630 #[serde(default = "default_bind")]
632 pub bind: String,
633
634 pub static_dir: Option<PathBuf>,
636
637 #[serde(default = "default_workers")]
639 pub workers: usize,
640
641 #[serde(default, flatten)]
643 pub extra: BTreeMap<String, Value>,
644}
645
646impl Default for ServerConfig {
647 fn default() -> Self {
648 Self {
649 port: default_port(),
650 bind: default_bind(),
651 static_dir: None,
652 workers: default_workers(),
653 extra: BTreeMap::new(),
654 }
655 }
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct ProxyAuth {
661 pub username: String,
663 pub password: String,
665}
666
667fn parse_bool_env(value: &str) -> bool {
671 matches!(
672 value.trim().to_ascii_lowercase().as_str(),
673 "1" | "true" | "yes" | "y" | "on"
674 )
675}
676
677impl Default for Config {
678 fn default() -> Self {
679 Self::new()
680 }
681}
682
683#[derive(Debug, Clone, PartialEq, Eq)]
685pub struct PromptSafeEnvVarEntry {
686 pub name: String,
687 pub secret: bool,
688 pub description: Option<String>,
689}
690
691static ENV_VARS_CACHE: std::sync::LazyLock<RwLock<HashMap<String, String>>> =
695 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
696
697static PROMPT_SAFE_ENV_VARS_CACHE: std::sync::LazyLock<RwLock<Vec<PromptSafeEnvVarEntry>>> =
698 std::sync::LazyLock::new(|| RwLock::new(Vec::new()));
699
700impl Config {
701 pub fn new() -> Self {
716 Self::from_data_dir(None)
717 }
718
719 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
725 let data_dir = data_dir
727 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
728 .unwrap_or_else(default_data_dir);
729
730 let config_path = data_dir.join("config.json");
731
732 let mut config = if config_path.exists() {
733 if let Ok(content) = std::fs::read_to_string(&config_path) {
734 serde_json::from_str::<Config>(&content)
735 .map(|mut config| {
736 config.hydrate_proxy_auth_from_encrypted();
737 config.hydrate_provider_api_keys_from_encrypted();
738 config.hydrate_mcp_secrets_from_encrypted();
739 config.hydrate_env_vars_from_encrypted();
740 config.normalize_tool_settings();
741 config.normalize_skill_settings();
742 config
743 })
744 .unwrap_or_else(|e| {
745 tracing::warn!("Failed to parse config.json ({}), using defaults", e);
746 Self::create_default()
747 })
748 } else {
749 Self::create_default()
750 }
751 } else {
752 Self::create_default()
753 };
754
755 config.hydrate_proxy_auth_from_encrypted();
757 config.hydrate_provider_api_keys_from_encrypted();
759 config.hydrate_mcp_secrets_from_encrypted();
761 config.hydrate_env_vars_from_encrypted();
763 config.normalize_tool_settings();
764 config.normalize_skill_settings();
765
766 config.extra.remove("data_dir");
769
770 if let Ok(port) = std::env::var("BAMBOO_PORT") {
772 if let Ok(port) = port.parse() {
773 config.server.port = port;
774 }
775 }
776
777 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
778 config.server.bind = bind;
779 }
780
781 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
783 config.provider = provider;
784 }
785
786 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
787 config.headless_auth = parse_bool_env(&headless);
788 }
789
790 config.publish_env_vars();
792
793 config
794 }
795
796 pub fn get_model(&self) -> Option<String> {
801 match self.provider.as_str() {
802 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
803 "anthropic" => self
804 .providers
805 .anthropic
806 .as_ref()
807 .and_then(|c| c.model.clone()),
808 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
809 "copilot" => Some(
810 self.providers
811 .copilot
812 .as_ref()
813 .and_then(|c| c.model.clone())
814 .unwrap_or_else(|| "gpt-4o".to_string()),
815 ),
816 _ => None,
817 }
818 }
819
820 pub fn get_fast_model(&self) -> Option<String> {
825 let fast = match self.provider.as_str() {
826 "openai" => self
827 .providers
828 .openai
829 .as_ref()
830 .and_then(|c| c.fast_model.clone()),
831 "anthropic" => self
832 .providers
833 .anthropic
834 .as_ref()
835 .and_then(|c| c.fast_model.clone()),
836 "gemini" => self
837 .providers
838 .gemini
839 .as_ref()
840 .and_then(|c| c.fast_model.clone()),
841 "copilot" => self
842 .providers
843 .copilot
844 .as_ref()
845 .and_then(|c| c.fast_model.clone()),
846 _ => None,
847 };
848 fast.or_else(|| self.get_model())
849 }
850
851 pub fn get_vision_model(&self) -> Option<String> {
856 let vision = match self.provider.as_str() {
857 "openai" => self
858 .providers
859 .openai
860 .as_ref()
861 .and_then(|c| c.vision_model.clone()),
862 "anthropic" => self
863 .providers
864 .anthropic
865 .as_ref()
866 .and_then(|c| c.vision_model.clone()),
867 "gemini" => self
868 .providers
869 .gemini
870 .as_ref()
871 .and_then(|c| c.vision_model.clone()),
872 "copilot" => self
873 .providers
874 .copilot
875 .as_ref()
876 .and_then(|c| c.vision_model.clone()),
877 _ => None,
878 };
879 vision.or_else(|| self.get_model())
880 }
881
882 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
884 match self.provider.as_str() {
885 "openai" => self
886 .providers
887 .openai
888 .as_ref()
889 .and_then(|c| c.reasoning_effort),
890 "anthropic" => self
891 .providers
892 .anthropic
893 .as_ref()
894 .and_then(|c| c.reasoning_effort),
895 "gemini" => self
896 .providers
897 .gemini
898 .as_ref()
899 .and_then(|c| c.reasoning_effort),
900 "copilot" => self
901 .providers
902 .copilot
903 .as_ref()
904 .and_then(|c| c.reasoning_effort),
905 _ => None,
906 }
907 }
908
909 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
911 self.tools
912 .disabled
913 .iter()
914 .map(|name| name.trim())
915 .filter(|name| !name.is_empty())
916 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
917 .collect()
918 }
919
920 pub fn normalize_tool_settings(&mut self) {
922 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
923 }
924
925 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
927 self.skills
928 .disabled
929 .iter()
930 .map(|id| id.trim())
931 .filter(|id| !id.is_empty())
932 .map(|id| id.to_string())
933 .collect()
934 }
935
936 pub fn normalize_skill_settings(&mut self) {
938 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
939 }
940
941 pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
947 if self.proxy_auth.is_some() {
948 return;
949 }
950
951 if self
958 .proxy_auth_encrypted
959 .as_deref()
960 .map(|s| s.trim().is_empty())
961 .unwrap_or(true)
962 {
963 let legacy = self
964 .extra
965 .get("https_proxy_auth_encrypted")
966 .and_then(|v| v.as_str())
967 .or_else(|| {
968 self.extra
969 .get("http_proxy_auth_encrypted")
970 .and_then(|v| v.as_str())
971 })
972 .map(|s| s.trim())
973 .filter(|s| !s.is_empty())
974 .map(|s| s.to_string());
975
976 if let Some(legacy) = legacy {
977 self.proxy_auth_encrypted = Some(legacy);
978 }
979 }
980
981 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
982 return;
983 };
984
985 match crate::core::encryption::decrypt(encrypted) {
986 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
987 Ok(auth) => {
988 self.proxy_auth = Some(auth);
989 self.extra.remove("http_proxy_auth_encrypted");
992 self.extra.remove("https_proxy_auth_encrypted");
993 }
994 Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
995 },
996 Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
997 }
998 }
999
1000 pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
1005 let Some(auth) = self.proxy_auth.as_ref() else {
1009 self.proxy_auth_encrypted = None;
1010 return Ok(());
1011 };
1012
1013 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
1014 let encrypted =
1015 crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
1016 self.proxy_auth_encrypted = Some(encrypted);
1017 Ok(())
1018 }
1019
1020 pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
1021 if let Some(openai) = self.providers.openai.as_mut() {
1022 if openai.api_key.trim().is_empty() {
1023 if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
1024 match crate::core::encryption::decrypt(encrypted) {
1025 Ok(value) => openai.api_key = value,
1026 Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
1027 }
1028 }
1029 }
1030 }
1031
1032 if let Some(anthropic) = self.providers.anthropic.as_mut() {
1033 if anthropic.api_key.trim().is_empty() {
1034 if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
1035 match crate::core::encryption::decrypt(encrypted) {
1036 Ok(value) => anthropic.api_key = value,
1037 Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
1038 }
1039 }
1040 }
1041 }
1042
1043 if let Some(gemini) = self.providers.gemini.as_mut() {
1044 if gemini.api_key.trim().is_empty() {
1045 if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
1046 match crate::core::encryption::decrypt(encrypted) {
1047 Ok(value) => gemini.api_key = value,
1048 Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
1049 }
1050 }
1051 }
1052 }
1053 }
1054
1055 pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
1056 if let Some(openai) = self.providers.openai.as_mut() {
1057 let api_key = openai.api_key.trim();
1058 openai.api_key_encrypted = if api_key.is_empty() {
1059 None
1060 } else {
1061 Some(
1062 crate::core::encryption::encrypt(api_key)
1063 .context("Failed to encrypt OpenAI api_key")?,
1064 )
1065 };
1066 }
1067
1068 if let Some(anthropic) = self.providers.anthropic.as_mut() {
1069 let api_key = anthropic.api_key.trim();
1070 anthropic.api_key_encrypted = if api_key.is_empty() {
1071 None
1072 } else {
1073 Some(
1074 crate::core::encryption::encrypt(api_key)
1075 .context("Failed to encrypt Anthropic api_key")?,
1076 )
1077 };
1078 }
1079
1080 if let Some(gemini) = self.providers.gemini.as_mut() {
1081 let api_key = gemini.api_key.trim();
1082 gemini.api_key_encrypted = if api_key.is_empty() {
1083 None
1084 } else {
1085 Some(
1086 crate::core::encryption::encrypt(api_key)
1087 .context("Failed to encrypt Gemini api_key")?,
1088 )
1089 };
1090 }
1091
1092 Ok(())
1093 }
1094
1095 pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
1096 for server in self.mcp.servers.iter_mut() {
1097 match &mut server.transport {
1098 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1099 if stdio.env_encrypted.is_empty() {
1100 continue;
1101 }
1102
1103 for (key, encrypted) in stdio.env_encrypted.clone() {
1105 let should_hydrate = stdio
1106 .env
1107 .get(&key)
1108 .map(|v| v.trim().is_empty())
1109 .unwrap_or(true);
1110 if !should_hydrate {
1111 continue;
1112 }
1113
1114 match crate::core::encryption::decrypt(&encrypted) {
1115 Ok(value) => {
1116 stdio.env.insert(key, value);
1117 }
1118 Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
1119 }
1120 }
1121 }
1122 crate::agent::mcp::TransportConfig::Sse(sse) => {
1123 for header in sse.headers.iter_mut() {
1124 if !header.value.trim().is_empty() {
1125 continue;
1126 }
1127 let Some(encrypted) = header.value_encrypted.as_deref() else {
1128 continue;
1129 };
1130 match crate::core::encryption::decrypt(encrypted) {
1131 Ok(value) => header.value = value,
1132 Err(e) => {
1133 tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
1134 }
1135 }
1136 }
1137 }
1138 }
1139 }
1140 }
1141
1142 pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
1143 for server in self.mcp.servers.iter_mut() {
1144 match &mut server.transport {
1145 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1146 stdio.env_encrypted.clear();
1147 for (key, value) in &stdio.env {
1148 let encrypted =
1149 crate::core::encryption::encrypt(value).with_context(|| {
1150 format!("Failed to encrypt MCP stdio env var '{key}'")
1151 })?;
1152 stdio.env_encrypted.insert(key.clone(), encrypted);
1153 }
1154 }
1155 crate::agent::mcp::TransportConfig::Sse(sse) => {
1156 for header in sse.headers.iter_mut() {
1157 let configured = !header.value.trim().is_empty();
1158 header.value_encrypted = if !configured {
1159 None
1160 } else {
1161 Some(
1162 crate::core::encryption::encrypt(&header.value).with_context(
1163 || {
1164 format!(
1165 "Failed to encrypt MCP SSE header '{}'",
1166 header.name
1167 )
1168 },
1169 )?,
1170 )
1171 };
1172 }
1173 }
1174 }
1175 }
1176
1177 Ok(())
1178 }
1179
1180 pub fn hydrate_env_vars_from_encrypted(&mut self) {
1184 for entry in &mut self.env_vars {
1185 if !entry.secret {
1186 continue;
1187 }
1188 if !entry.value.trim().is_empty() {
1189 continue;
1191 }
1192 let Some(encrypted) = &entry.value_encrypted else {
1193 continue;
1194 };
1195 match crate::core::encryption::decrypt(encrypted) {
1196 Ok(value) => entry.value = value,
1197 Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
1198 }
1199 }
1200 }
1201
1202 pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
1204 for entry in &mut self.env_vars {
1205 if entry.secret && !entry.value.trim().is_empty() {
1206 entry.value_encrypted = Some(
1207 crate::core::encryption::encrypt(&entry.value)
1208 .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
1209 );
1210 } else if !entry.secret {
1211 entry.value_encrypted = None;
1212 }
1213 }
1214 Ok(())
1215 }
1216
1217 pub fn sanitize_env_vars_for_disk(&mut self) {
1219 for entry in &mut self.env_vars {
1220 if entry.secret {
1221 entry.value = String::new();
1222 }
1223 }
1224 }
1225
1226 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
1228 self.env_vars
1229 .iter()
1230 .filter(|e| !e.value.trim().is_empty())
1231 .map(|e| (e.name.clone(), e.value.clone()))
1232 .collect()
1233 }
1234
1235 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
1236 self.env_vars
1237 .iter()
1238 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
1239 .map(|entry| PromptSafeEnvVarEntry {
1240 name: entry.name.clone(),
1241 secret: entry.secret,
1242 description: entry
1243 .description
1244 .as_ref()
1245 .map(|value| value.trim().to_string())
1246 .filter(|value| !value.is_empty()),
1247 })
1248 .collect()
1249 }
1250
1251 pub fn publish_env_vars(&self) {
1253 let map = self.env_vars_as_map();
1254 if let Ok(mut guard) = ENV_VARS_CACHE.write() {
1255 *guard = map;
1256 }
1257 let prompt_safe = self.prompt_safe_env_vars();
1258 if let Ok(mut guard) = PROMPT_SAFE_ENV_VARS_CACHE.write() {
1259 *guard = prompt_safe;
1260 }
1261 }
1262
1263 pub fn current_env_vars() -> HashMap<String, String> {
1265 ENV_VARS_CACHE
1266 .read()
1267 .map(|guard| guard.clone())
1268 .unwrap_or_default()
1269 }
1270
1271 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
1273 PROMPT_SAFE_ENV_VARS_CACHE
1274 .read()
1275 .map(|guard| guard.clone())
1276 .unwrap_or_default()
1277 }
1278
1279 fn create_default() -> Self {
1281 Config {
1282 http_proxy: String::new(),
1283 https_proxy: String::new(),
1284 proxy_auth: None,
1285 proxy_auth_encrypted: None,
1286 headless_auth: false,
1287 provider: default_provider(),
1288 providers: ProviderConfigs::default(),
1289 server: ServerConfig::default(),
1290 keyword_masking: KeywordMaskingConfig::default(),
1291 anthropic_model_mapping: AnthropicModelMapping::default(),
1292 gemini_model_mapping: GeminiModelMapping::default(),
1293 hooks: HooksConfig::default(),
1294 tools: ToolsConfig::default(),
1295 skills: SkillsConfig::default(),
1296 env_vars: Vec::new(),
1297 mcp: crate::agent::mcp::McpConfig::default(),
1298 extra: BTreeMap::new(),
1299 }
1300 }
1301
1302 pub fn server_addr(&self) -> String {
1304 format!("{}:{}", self.server.bind, self.server.port)
1305 }
1306
1307 pub fn save(&self) -> Result<()> {
1309 self.save_to_dir(default_data_dir())
1310 }
1311
1312 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
1316 let path = data_dir.join("config.json");
1317
1318 if let Some(parent) = path.parent() {
1319 std::fs::create_dir_all(parent)
1320 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
1321 }
1322
1323 let mut to_save = self.clone();
1324 to_save.extra.remove("data_dir");
1326 to_save.extra.remove("model");
1328 to_save.refresh_proxy_auth_encrypted()?;
1329 to_save.refresh_provider_api_keys_encrypted()?;
1330 to_save.refresh_env_vars_encrypted()?;
1331 to_save.sanitize_env_vars_for_disk();
1332 to_save.normalize_tool_settings();
1333 to_save.normalize_skill_settings();
1334 let content =
1335 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
1336 write_atomic(&path, content.as_bytes())
1337 .with_context(|| format!("Failed to write config file: {:?}", path))?;
1338
1339 Ok(())
1340 }
1341}
1342
1343fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
1344 let Some(parent) = path.parent() else {
1345 return std::fs::write(path, content);
1346 };
1347
1348 std::fs::create_dir_all(parent)?;
1349
1350 let file_name = path
1353 .file_name()
1354 .and_then(|s| s.to_str())
1355 .unwrap_or("config.json");
1356 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1357 let tmp_path = parent.join(tmp_name);
1358
1359 {
1360 let mut file = std::fs::File::create(&tmp_path)?;
1361 file.write_all(content)?;
1362 file.sync_all()?;
1363 }
1364
1365 std::fs::rename(&tmp_path, path)?;
1366 Ok(())
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371 use super::*;
1372 use std::ffi::OsString;
1373 use std::path::PathBuf;
1374 use std::sync::{Mutex, OnceLock};
1375 use std::time::{SystemTime, UNIX_EPOCH};
1376
1377 struct EnvVarGuard {
1378 key: &'static str,
1379 previous: Option<OsString>,
1380 }
1381
1382 impl EnvVarGuard {
1383 fn set(key: &'static str, value: &str) -> Self {
1384 let previous = std::env::var_os(key);
1385 std::env::set_var(key, value);
1386 Self { key, previous }
1387 }
1388
1389 fn unset(key: &'static str) -> Self {
1390 let previous = std::env::var_os(key);
1391 std::env::remove_var(key);
1392 Self { key, previous }
1393 }
1394 }
1395
1396 impl Drop for EnvVarGuard {
1397 fn drop(&mut self) {
1398 match &self.previous {
1399 Some(value) => std::env::set_var(self.key, value),
1400 None => std::env::remove_var(self.key),
1401 }
1402 }
1403 }
1404
1405 struct TempHome {
1406 path: PathBuf,
1407 }
1408
1409 impl TempHome {
1410 fn new() -> Self {
1411 let nanos = SystemTime::now()
1412 .duration_since(UNIX_EPOCH)
1413 .expect("clock should be after unix epoch")
1414 .as_nanos();
1415 let path = std::env::temp_dir().join(format!(
1416 "chat-core-config-test-{}-{}",
1417 std::process::id(),
1418 nanos
1419 ));
1420 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1421 Self { path }
1422 }
1423
1424 fn set_config_json(&self, content: &str) {
1425 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1428 std::fs::write(self.path.join("config.json"), content)
1429 .expect("failed to write config.json");
1430 }
1431 }
1432
1433 impl Drop for TempHome {
1434 fn drop(&mut self) {
1435 let _ = std::fs::remove_dir_all(&self.path);
1436 }
1437 }
1438
1439 fn env_lock() -> &'static Mutex<()> {
1440 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1441 LOCK.get_or_init(|| Mutex::new(()))
1442 }
1443
1444 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1446 env_lock().lock().unwrap_or_else(|poisoned| {
1447 poisoned.into_inner()
1449 })
1450 }
1451
1452 #[test]
1453 fn parse_bool_env_true_values() {
1454 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1455 assert!(parse_bool_env(value), "value {value:?} should be true");
1456 }
1457 }
1458
1459 #[test]
1460 fn parse_bool_env_false_values() {
1461 for value in ["0", "false", "no", "off", "", " "] {
1462 assert!(!parse_bool_env(value), "value {value:?} should be false");
1463 }
1464 }
1465
1466 #[test]
1467 fn config_new_ignores_http_proxy_env_vars() {
1468 let _lock = env_lock_acquire();
1469 let temp_home = TempHome::new();
1470 temp_home.set_config_json(
1471 r#"{
1472 "http_proxy": "",
1473 "https_proxy": ""
1474}"#,
1475 );
1476
1477 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1478 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1479
1480 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1481
1482 assert!(
1483 config.http_proxy.is_empty(),
1484 "config should ignore HTTP_PROXY env var"
1485 );
1486 assert!(
1487 config.https_proxy.is_empty(),
1488 "config should ignore HTTPS_PROXY env var"
1489 );
1490 }
1491
1492 #[test]
1493 fn config_new_loads_config_when_proxy_fields_omitted() {
1494 let _lock = env_lock_acquire();
1495 let temp_home = TempHome::new();
1496 temp_home.set_config_json(
1497 r#"{
1498 "provider": "openai",
1499 "providers": {
1500 "openai": {
1501 "api_key": "sk-test",
1502 "model": "gpt-4o"
1503 }
1504 }
1505}"#,
1506 );
1507
1508 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1509 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1510
1511 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1512
1513 assert_eq!(
1514 config
1515 .providers
1516 .openai
1517 .as_ref()
1518 .and_then(|c| c.model.as_deref()),
1519 Some("gpt-4o"),
1520 "config should load provider model from config file even when proxy fields are omitted"
1521 );
1522 assert!(config.http_proxy.is_empty());
1523 assert!(config.https_proxy.is_empty());
1524 }
1525
1526 #[test]
1527 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
1528 let mut config = Config::default();
1529 config.env_vars = vec![
1530 EnvVarEntry {
1531 name: "SECRET_TOKEN".to_string(),
1532 value: "top-secret".to_string(),
1533 secret: true,
1534 value_encrypted: None,
1535 description: Some("Service token".to_string()),
1536 },
1537 EnvVarEntry {
1538 name: "API_BASE".to_string(),
1539 value: "https://internal.example".to_string(),
1540 secret: false,
1541 value_encrypted: None,
1542 description: Some("Internal API base".to_string()),
1543 },
1544 ];
1545
1546 config.publish_env_vars();
1547
1548 let injected = Config::current_env_vars();
1549 assert_eq!(
1550 injected.get("SECRET_TOKEN").map(String::as_str),
1551 Some("top-secret")
1552 );
1553 assert_eq!(
1554 injected.get("API_BASE").map(String::as_str),
1555 Some("https://internal.example")
1556 );
1557
1558 let prompt_safe = Config::current_prompt_safe_env_vars();
1559 assert_eq!(prompt_safe.len(), 2);
1560 assert!(prompt_safe.iter().any(|entry| {
1561 entry.name == "SECRET_TOKEN"
1562 && entry.secret
1563 && entry.description.as_deref() == Some("Service token")
1564 }));
1565 assert!(prompt_safe.iter().any(|entry| {
1566 entry.name == "API_BASE"
1567 && !entry.secret
1568 && entry.description.as_deref() == Some("Internal API base")
1569 }));
1570 assert!(!prompt_safe
1571 .iter()
1572 .any(|entry| entry.name.contains("top-secret")));
1573 assert!(!prompt_safe.iter().any(|entry| {
1574 entry
1575 .description
1576 .as_deref()
1577 .is_some_and(|value| value.contains("https://internal.example"))
1578 }));
1579 }
1580
1581 #[test]
1582 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
1583 let _lock = env_lock_acquire();
1584 let temp_home = TempHome::new();
1585 temp_home.set_config_json(
1586 r#"{
1587 "provider": "openai",
1588 "providers": {
1589 "openai": {
1590 "api_key": "sk-test",
1591 "model": "gpt-4o"
1592 }
1593 }
1594}"#,
1595 );
1596
1597 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1598 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1599
1600 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1601
1602 assert_eq!(
1603 config
1604 .providers
1605 .openai
1606 .as_ref()
1607 .and_then(|c| c.model.as_deref()),
1608 Some("gpt-4o")
1609 );
1610 assert!(
1611 config.http_proxy.is_empty(),
1612 "config should keep http_proxy empty when field is omitted"
1613 );
1614 assert!(
1615 config.https_proxy.is_empty(),
1616 "config should keep https_proxy empty when field is omitted"
1617 );
1618 }
1619
1620 #[test]
1621 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
1622 let mut config = Config::default();
1623 config.tools.disabled = vec![
1624 " read_file ".to_string(),
1625 "".to_string(),
1626 "read_file".to_string(),
1627 "bash".to_string(),
1628 "default::getCurrentDir".to_string(),
1629 ];
1630
1631 config.normalize_tool_settings();
1632
1633 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
1634 }
1635
1636 #[test]
1637 fn config_load_reads_disabled_tools_as_canonical_names() {
1638 let _lock = env_lock_acquire();
1639 let temp_home = TempHome::new();
1640 temp_home.set_config_json(
1641 r#"{
1642 "tools": {
1643 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
1644 }
1645}"#,
1646 );
1647
1648 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1649 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
1650 assert!(config.disabled_tool_names().contains("Bash"));
1651 assert!(config.disabled_tool_names().contains("Read"));
1652 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
1653 }
1654
1655 #[test]
1656 fn normalize_skill_settings_trims_dedupes_and_sorts() {
1657 let mut config = Config::default();
1658 config.skills.disabled = vec![
1659 " pdf ".to_string(),
1660 "".to_string(),
1661 "pdf".to_string(),
1662 "skill-creator".to_string(),
1663 ];
1664
1665 config.normalize_skill_settings();
1666
1667 assert_eq!(
1668 config.skills.disabled,
1669 vec!["pdf".to_string(), "skill-creator".to_string()]
1670 );
1671 }
1672
1673 #[test]
1674 fn config_load_reads_disabled_skills_as_normalized_ids() {
1675 let _lock = env_lock_acquire();
1676 let temp_home = TempHome::new();
1677 temp_home.set_config_json(
1678 r#"{
1679 "skills": {
1680 "disabled": [" pdf ", "skill-creator", "pdf", ""]
1681 }
1682}"#,
1683 );
1684
1685 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1686 assert_eq!(
1687 config.skills.disabled,
1688 vec!["pdf".to_string(), "skill-creator".to_string()]
1689 );
1690 assert!(config.disabled_skill_ids().contains("pdf"));
1691 assert!(config.disabled_skill_ids().contains("skill-creator"));
1692 }
1693
1694 #[test]
1695 fn test_server_config_defaults() {
1696 let _lock = env_lock_acquire();
1697 let temp_home = TempHome::new();
1698
1699 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1700 assert_eq!(config.server.port, 9562);
1701 assert_eq!(config.server.bind, "127.0.0.1");
1702 assert_eq!(config.server.workers, 10);
1703 assert!(config.server.static_dir.is_none());
1704 }
1705
1706 #[test]
1707 fn test_server_addr() {
1708 let mut config = Config::default();
1709 config.server.port = 9000;
1710 config.server.bind = "0.0.0.0".to_string();
1711 assert_eq!(config.server_addr(), "0.0.0.0:9000");
1712 }
1713
1714 #[test]
1715 fn test_env_var_overrides() {
1716 let _lock = env_lock_acquire();
1717 let temp_home = TempHome::new();
1718
1719 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1720 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1721 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1722
1723 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1724 assert_eq!(config.server.port, 9999);
1725 assert_eq!(config.server.bind, "192.168.1.1");
1726 assert_eq!(config.provider, "openai");
1727 }
1728
1729 #[test]
1730 fn test_config_save_and_load() {
1731 let _lock = env_lock_acquire();
1732 let temp_home = TempHome::new();
1733
1734 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1735 config.server.port = 9000;
1736 config.server.bind = "0.0.0.0".to_string();
1737 config.provider = "anthropic".to_string();
1738
1739 config
1741 .save_to_dir(temp_home.path.clone())
1742 .expect("Failed to save config");
1743
1744 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1746
1747 assert_eq!(loaded.server.port, 9000);
1749 assert_eq!(loaded.server.bind, "0.0.0.0");
1750 assert_eq!(loaded.provider, "anthropic");
1751 }
1752
1753 #[test]
1754 fn config_decrypts_proxy_auth_from_encrypted_field() {
1755 let _lock = env_lock_acquire();
1756 let temp_home = TempHome::new();
1757
1758 let key_guard = crate::core::encryption::set_test_encryption_key([
1760 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1761 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1762 0x1c, 0x1d, 0x1e, 0x1f,
1763 ]);
1764
1765 let auth = ProxyAuth {
1766 username: "user".to_string(),
1767 password: "pass".to_string(),
1768 };
1769 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1770 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1771
1772 temp_home.set_config_json(&format!(
1773 r#"{{
1774 "http_proxy": "http://proxy.example.com:8080",
1775 "proxy_auth_encrypted": "{encrypted}"
1776}}"#
1777 ));
1778 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1779 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1780 assert_eq!(loaded_auth.username, "user");
1781 assert_eq!(loaded_auth.password, "pass");
1782 drop(key_guard);
1783 }
1784
1785 #[test]
1786 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
1787 let _lock = env_lock_acquire();
1788 let temp_home = TempHome::new();
1789
1790 let key_guard = crate::core::encryption::set_test_encryption_key([
1792 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1793 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1794 0x1c, 0x1d, 0x1e, 0x1f,
1795 ]);
1796
1797 let auth = ProxyAuth {
1798 username: "user".to_string(),
1799 password: "pass".to_string(),
1800 };
1801 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1802 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1803
1804 temp_home.set_config_json(&format!(
1806 r#"{{
1807 "http_proxy": "http://proxy.example.com:8080",
1808 "http_proxy_auth_encrypted": "{encrypted}",
1809 "https_proxy_auth_encrypted": "{encrypted}"
1810}}"#
1811 ));
1812
1813 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1814 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1815 assert_eq!(loaded_auth.username, "user");
1816 assert_eq!(loaded_auth.password, "pass");
1817 drop(key_guard);
1818 }
1819
1820 #[test]
1821 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1822 let _lock = env_lock_acquire();
1823 let temp_home = TempHome::new();
1824
1825 let key_guard = crate::core::encryption::set_test_encryption_key([
1827 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1828 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1829 0x1c, 0x1d, 0x1e, 0x1f,
1830 ]);
1831
1832 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1833 config.proxy_auth = Some(ProxyAuth {
1834 username: "user".to_string(),
1835 password: "pass".to_string(),
1836 });
1837 config
1838 .save_to_dir(temp_home.path.clone())
1839 .expect("save should encrypt proxy auth");
1840
1841 let content =
1842 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1843 assert!(
1844 content.contains("proxy_auth_encrypted"),
1845 "config.json should store encrypted proxy auth"
1846 );
1847 assert!(
1848 !content.contains("\"proxy_auth\""),
1849 "config.json should not store plaintext proxy_auth"
1850 );
1851
1852 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1853 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1854 assert_eq!(loaded_auth.username, "user");
1855 assert_eq!(loaded_auth.password, "pass");
1856 drop(key_guard);
1857 }
1858
1859 #[test]
1860 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
1861 let _lock = env_lock_acquire();
1862 let temp_home = TempHome::new();
1863
1864 let key_guard = crate::core::encryption::set_test_encryption_key([
1866 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1867 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1868 0x1c, 0x1d, 0x1e, 0x1f,
1869 ]);
1870
1871 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1872 config.provider = "openai".to_string();
1873 config.providers.openai = Some(OpenAIConfig {
1874 api_key: "sk-test-provider-key".to_string(),
1875 api_key_encrypted: None,
1876 base_url: None,
1877 model: None,
1878 fast_model: None,
1879 vision_model: None,
1880 reasoning_effort: None,
1881 responses_only_models: vec![],
1882 request_overrides: None,
1883 extra: Default::default(),
1884 });
1885
1886 config
1887 .save_to_dir(temp_home.path.clone())
1888 .expect("save should encrypt provider api keys");
1889
1890 let content =
1891 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1892 assert!(
1893 content.contains("\"api_key_encrypted\""),
1894 "config.json should store encrypted provider keys"
1895 );
1896 assert!(
1897 !content.contains("\"api_key\""),
1898 "config.json should not store plaintext provider keys"
1899 );
1900
1901 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1902 let openai = loaded
1903 .providers
1904 .openai
1905 .expect("openai config should be present");
1906 assert_eq!(openai.api_key, "sk-test-provider-key");
1907
1908 drop(key_guard);
1909 }
1910
1911 #[test]
1912 fn config_save_persists_mcp_servers_in_mainstream_format() {
1913 let _lock = env_lock_acquire();
1914 let temp_home = TempHome::new();
1915
1916 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1917
1918 let mut env = std::collections::HashMap::new();
1919 env.insert("TOKEN".to_string(), "supersecret".to_string());
1920
1921 config.mcp.servers = vec![
1922 crate::agent::mcp::McpServerConfig {
1923 id: "stdio-secret".to_string(),
1924 name: None,
1925 enabled: true,
1926 transport: crate::agent::mcp::TransportConfig::Stdio(
1927 crate::agent::mcp::StdioConfig {
1928 command: "echo".to_string(),
1929 args: vec![],
1930 cwd: None,
1931 env,
1932 env_encrypted: std::collections::HashMap::new(),
1933 startup_timeout_ms: 5000,
1934 },
1935 ),
1936 request_timeout_ms: 5000,
1937 healthcheck_interval_ms: 1000,
1938 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1939 allowed_tools: vec![],
1940 denied_tools: vec![],
1941 },
1942 crate::agent::mcp::McpServerConfig {
1943 id: "sse-secret".to_string(),
1944 name: None,
1945 enabled: true,
1946 transport: crate::agent::mcp::TransportConfig::Sse(crate::agent::mcp::SseConfig {
1947 url: "http://localhost:8080/sse".to_string(),
1948 headers: vec![crate::agent::mcp::HeaderConfig {
1949 name: "Authorization".to_string(),
1950 value: "Bearer token123".to_string(),
1951 value_encrypted: None,
1952 }],
1953 connect_timeout_ms: 5000,
1954 }),
1955 request_timeout_ms: 5000,
1956 healthcheck_interval_ms: 1000,
1957 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1958 allowed_tools: vec![],
1959 denied_tools: vec![],
1960 },
1961 ];
1962
1963 config
1964 .save_to_dir(temp_home.path.clone())
1965 .expect("save should persist MCP servers");
1966
1967 let content =
1968 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1969 assert!(
1970 content.contains("\"mcpServers\""),
1971 "config.json should store MCP servers under the mainstream 'mcpServers' key"
1972 );
1973 assert!(
1974 content.contains("supersecret"),
1975 "config.json should persist MCP stdio env in mainstream format"
1976 );
1977 assert!(
1978 content.contains("Bearer token123"),
1979 "config.json should persist MCP SSE headers in mainstream format"
1980 );
1981 assert!(
1982 !content.contains("\"env_encrypted\""),
1983 "config.json should not persist legacy env_encrypted fields"
1984 );
1985 assert!(
1986 !content.contains("\"value_encrypted\""),
1987 "config.json should not persist legacy value_encrypted fields"
1988 );
1989
1990 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1991 let stdio = loaded
1992 .mcp
1993 .servers
1994 .iter()
1995 .find(|s| s.id == "stdio-secret")
1996 .expect("stdio server should exist");
1997 match &stdio.transport {
1998 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1999 assert_eq!(
2000 stdio.env.get("TOKEN").map(|s| s.as_str()),
2001 Some("supersecret")
2002 );
2003 }
2004 _ => panic!("Expected stdio transport"),
2005 }
2006
2007 let sse = loaded
2008 .mcp
2009 .servers
2010 .iter()
2011 .find(|s| s.id == "sse-secret")
2012 .expect("sse server should exist");
2013 match &sse.transport {
2014 crate::agent::mcp::TransportConfig::Sse(sse) => {
2015 assert_eq!(sse.headers[0].value, "Bearer token123");
2016 }
2017 _ => panic!("Expected SSE transport"),
2018 }
2019 }
2020
2021 #[test]
2024 fn env_vars_as_map_includes_only_non_empty_values() {
2025 let mut config = Config::default();
2026 config.env_vars = vec![
2027 EnvVarEntry {
2028 name: "A".to_string(),
2029 value: "val_a".to_string(),
2030 secret: false,
2031 value_encrypted: None,
2032 description: None,
2033 },
2034 EnvVarEntry {
2035 name: "B".to_string(),
2036 value: "".to_string(), secret: true,
2038 value_encrypted: None,
2039 description: None,
2040 },
2041 EnvVarEntry {
2042 name: "C".to_string(),
2043 value: " ".to_string(), secret: false,
2045 value_encrypted: None,
2046 description: None,
2047 },
2048 EnvVarEntry {
2049 name: "D".to_string(),
2050 value: "val_d".to_string(),
2051 secret: true,
2052 value_encrypted: Some("enc".to_string()),
2053 description: Some("desc".to_string()),
2054 },
2055 ];
2056
2057 let map = config.env_vars_as_map();
2058 assert_eq!(map.len(), 2);
2059 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
2060 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
2061 assert!(!map.contains_key("B"));
2062 assert!(!map.contains_key("C"));
2063 }
2064
2065 #[test]
2066 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
2067 let mut config = Config::default();
2068 config.env_vars = vec![
2069 EnvVarEntry {
2070 name: "PLAIN".to_string(),
2071 value: "visible".to_string(),
2072 secret: false,
2073 value_encrypted: None,
2074 description: None,
2075 },
2076 EnvVarEntry {
2077 name: "SECRET".to_string(),
2078 value: "hidden_value".to_string(),
2079 secret: true,
2080 value_encrypted: Some("enc_data".to_string()),
2081 description: None,
2082 },
2083 ];
2084
2085 config.sanitize_env_vars_for_disk();
2086
2087 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
2090
2091 #[test]
2092 fn sanitize_env_vars_for_disk_preserves_encrypted() {
2093 let mut config = Config::default();
2094 config.env_vars = vec![
2095 EnvVarEntry {
2096 name: "OPEN".to_string(),
2097 value: "val".to_string(),
2098 secret: false,
2099 value_encrypted: None,
2100 description: None,
2101 },
2102 EnvVarEntry {
2103 name: "HIDDEN".to_string(),
2104 value: "real_secret".to_string(),
2105 secret: true,
2106 value_encrypted: Some("enc".to_string()),
2107 description: None,
2108 },
2109 ];
2110
2111 config.sanitize_env_vars_for_disk();
2112
2113 assert_eq!(config.env_vars[0].value, "val");
2115 assert_eq!(config.env_vars[1].value, "");
2117 assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
2118 }
2119
2120 #[test]
2121 fn refresh_env_vars_encrypted_round_trip() {
2122 let mut config = Config::default();
2123 config.env_vars = vec![
2124 EnvVarEntry {
2125 name: "TOKEN".to_string(),
2126 value: "my-secret-token".to_string(),
2127 secret: true,
2128 value_encrypted: None,
2129 description: Some("A token".to_string()),
2130 },
2131 EnvVarEntry {
2132 name: "PLAIN_VAR".to_string(),
2133 value: "hello".to_string(),
2134 secret: false,
2135 value_encrypted: None,
2136 description: None,
2137 },
2138 ];
2139
2140 config
2142 .refresh_env_vars_encrypted()
2143 .expect("encryption should succeed");
2144
2145 assert!(config.env_vars[0].value_encrypted.is_some());
2147 assert!(config.env_vars[1].value_encrypted.is_none());
2149
2150 let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
2152 assert_ne!(encrypted, "my-secret-token"); config.sanitize_env_vars_for_disk();
2156 assert_eq!(config.env_vars[0].value, "");
2157
2158 config.hydrate_env_vars_from_encrypted();
2160 assert_eq!(config.env_vars[0].value, "my-secret-token");
2161 assert_eq!(config.env_vars[1].value, "hello"); }
2163
2164 #[test]
2165 fn publish_and_current_env_vars_round_trip() {
2166 let mut config = Config::default();
2167 config.env_vars = vec![EnvVarEntry {
2168 name: "TEST_PUBLISH".to_string(),
2169 value: "pub_value".to_string(),
2170 secret: false,
2171 value_encrypted: None,
2172 description: None,
2173 }];
2174
2175 config.publish_env_vars();
2176 let map = Config::current_env_vars();
2177 assert_eq!(map.get("TEST_PUBLISH"), Some(&"pub_value".to_string()));
2178 }
2179
2180 #[test]
2181 fn hydrate_skips_non_secret_entries() {
2182 let mut config = Config::default();
2183 config.env_vars = vec![EnvVarEntry {
2184 name: "PLAIN".to_string(),
2185 value: "original".to_string(),
2186 secret: false,
2187 value_encrypted: Some("should-be-ignored".to_string()),
2188 description: None,
2189 }];
2190
2191 config.hydrate_env_vars_from_encrypted();
2192 assert_eq!(config.env_vars[0].value, "original");
2194 }
2195
2196 #[test]
2197 fn default_config_has_empty_env_vars() {
2198 let config = Config::default();
2199 assert!(config.env_vars.is_empty());
2200 }
2201
2202 #[test]
2203 fn serde_round_trip_with_env_vars() {
2204 let mut config = Config::default();
2205 config.env_vars = vec![
2206 EnvVarEntry {
2207 name: "KEY1".to_string(),
2208 value: "val1".to_string(),
2209 secret: false,
2210 value_encrypted: None,
2211 description: Some("First key".to_string()),
2212 },
2213 EnvVarEntry {
2214 name: "KEY2".to_string(),
2215 value: "".to_string(), secret: true,
2217 value_encrypted: Some("enc123".to_string()),
2218 description: None,
2219 },
2220 ];
2221
2222 let json = serde_json::to_string(&config).unwrap();
2223 let restored: Config = serde_json::from_str(&json).unwrap();
2224
2225 assert_eq!(restored.env_vars.len(), 2);
2226 assert_eq!(restored.env_vars[0].name, "KEY1");
2227 assert_eq!(restored.env_vars[0].value, "val1");
2228 assert!(!restored.env_vars[0].secret);
2229 assert_eq!(restored.env_vars[1].name, "KEY2");
2230 assert!(restored.env_vars[1].secret);
2231 assert_eq!(
2232 restored.env_vars[1].value_encrypted.as_deref(),
2233 Some("enc123")
2234 );
2235 }
2236}