1use anyhow::{Context, Result};
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::collections::BTreeMap;
55use std::io::Write;
56use std::path::PathBuf;
57
58use crate::core::keyword_masking::KeywordMaskingConfig;
59use crate::core::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Config {
67 #[serde(default)]
69 pub http_proxy: String,
70 #[serde(default)]
72 pub https_proxy: String,
73 #[serde(skip_serializing)]
77 pub proxy_auth: Option<ProxyAuth>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub proxy_auth_encrypted: Option<String>,
84 pub model: Option<String>,
86 #[serde(default)]
88 pub headless_auth: bool,
89
90 #[serde(default = "default_provider")]
92 pub provider: String,
93
94 #[serde(default)]
96 pub providers: ProviderConfigs,
97
98 #[serde(default)]
100 pub server: ServerConfig,
101
102 #[serde(default)]
106 pub keyword_masking: KeywordMaskingConfig,
107
108 #[serde(default)]
112 pub anthropic_model_mapping: AnthropicModelMapping,
113
114 #[serde(default)]
118 pub gemini_model_mapping: GeminiModelMapping,
119
120 #[serde(default)]
125 pub hooks: HooksConfig,
126
127 #[serde(default, rename = "mcpServers", alias = "mcp")]
133 pub mcp: crate::agent::mcp::McpConfig,
134
135 #[serde(default, flatten)]
141 pub extra: BTreeMap<String, Value>,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148pub struct ProviderConfigs {
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub openai: Option<OpenAIConfig>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub anthropic: Option<AnthropicConfig>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub gemini: Option<GeminiConfig>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub copilot: Option<CopilotConfig>,
161
162 #[serde(default, flatten)]
164 pub extra: BTreeMap<String, Value>,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169pub struct HooksConfig {
170 #[serde(default)]
172 pub image_fallback: ImageFallbackHookConfig,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ImageFallbackHookConfig {
181 #[serde(default = "default_true_hooks")]
182 pub enabled: bool,
183
184 #[serde(default = "default_image_fallback_mode")]
186 pub mode: String,
187}
188
189impl Default for ImageFallbackHookConfig {
190 fn default() -> Self {
191 Self {
192 enabled: default_true_hooks(),
193 mode: default_image_fallback_mode(),
194 }
195 }
196}
197
198fn default_image_fallback_mode() -> String {
199 "placeholder".to_string()
200}
201
202fn default_true_hooks() -> bool {
203 true
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct OpenAIConfig {
219 #[serde(default, skip_serializing)]
223 pub api_key: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub api_key_encrypted: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub base_url: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub model: Option<String>,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub responses_only_models: Vec<String>,
242
243 #[serde(default, flatten)]
245 pub extra: BTreeMap<String, Value>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct AnthropicConfig {
261 #[serde(default, skip_serializing)]
265 pub api_key: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub api_key_encrypted: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub base_url: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub model: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub max_tokens: Option<u32>,
278
279 #[serde(default, flatten)]
281 pub extra: BTreeMap<String, Value>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct GeminiConfig {
296 #[serde(default, skip_serializing)]
300 pub api_key: String,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub api_key_encrypted: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub base_url: Option<String>,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub model: Option<String>,
310
311 #[serde(default, flatten)]
313 pub extra: BTreeMap<String, Value>,
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct CopilotConfig {
329 #[serde(default)]
331 pub enabled: bool,
332 #[serde(default)]
334 pub headless_auth: bool,
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub model: Option<String>,
338
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub responses_only_models: Vec<String>,
349
350 #[serde(default, flatten)]
352 pub extra: BTreeMap<String, Value>,
353}
354
355fn default_provider() -> String {
357 "anthropic".to_string()
358}
359
360fn default_port() -> u16 {
362 8080
363}
364
365fn default_bind() -> String {
367 "127.0.0.1".to_string()
368}
369
370fn default_workers() -> usize {
372 10
373}
374
375fn default_data_dir() -> PathBuf {
377 super::paths::bamboo_dir()
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct ServerConfig {
383 #[serde(default = "default_port")]
385 pub port: u16,
386
387 #[serde(default = "default_bind")]
389 pub bind: String,
390
391 pub static_dir: Option<PathBuf>,
393
394 #[serde(default = "default_workers")]
396 pub workers: usize,
397
398 #[serde(default, flatten)]
400 pub extra: BTreeMap<String, Value>,
401}
402
403impl Default for ServerConfig {
404 fn default() -> Self {
405 Self {
406 port: default_port(),
407 bind: default_bind(),
408 static_dir: None,
409 workers: default_workers(),
410 extra: BTreeMap::new(),
411 }
412 }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct ProxyAuth {
418 pub username: String,
420 pub password: String,
422}
423
424fn parse_bool_env(value: &str) -> bool {
428 matches!(
429 value.trim().to_ascii_lowercase().as_str(),
430 "1" | "true" | "yes" | "y" | "on"
431 )
432}
433
434impl Default for Config {
435 fn default() -> Self {
436 Self::new()
437 }
438}
439
440impl Config {
441 pub fn new() -> Self {
458 Self::from_data_dir(None)
459 }
460
461 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
467 let data_dir = data_dir
469 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
470 .unwrap_or_else(default_data_dir);
471
472 let config_path = data_dir.join("config.json");
473
474 let mut config = if config_path.exists() {
475 if let Ok(content) = std::fs::read_to_string(&config_path) {
476 if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
478 let has_old_fields = old_config.http_proxy_auth.is_some()
480 || old_config.https_proxy_auth.is_some()
481 || old_config.api_key.is_some()
482 || old_config.api_base.is_some();
483
484 if has_old_fields {
485 log::info!("Migrating old config format to new format");
486 let migrated = migrate_config(old_config);
487 if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
489 let _ = std::fs::write(&config_path, new_content);
490 }
491 migrated
492 } else {
493 match serde_json::from_str::<Config>(&content) {
497 Ok(mut config) => {
498 config.hydrate_proxy_auth_from_encrypted();
499 config.hydrate_provider_api_keys_from_encrypted();
500 config.hydrate_mcp_secrets_from_encrypted();
501 config
502 }
503 Err(_) => {
504 migrate_config(old_config)
506 }
507 }
508 }
509 } else {
510 serde_json::from_str::<Config>(&content)
512 .map(|mut config| {
513 config.hydrate_proxy_auth_from_encrypted();
514 config.hydrate_provider_api_keys_from_encrypted();
515 config.hydrate_mcp_secrets_from_encrypted();
516 config
517 })
518 .unwrap_or_else(|_| Self::create_default())
519 }
520 } else {
521 Self::create_default()
522 }
523 } else {
524 Self::create_default()
525 };
526
527 config.hydrate_proxy_auth_from_encrypted();
529 config.hydrate_provider_api_keys_from_encrypted();
531 config.hydrate_mcp_secrets_from_encrypted();
533
534 config.extra.remove("data_dir");
537
538 config.migrate_legacy_sidecar_configs(&data_dir);
541
542 if let Ok(port) = std::env::var("BAMBOO_PORT") {
544 if let Ok(port) = port.parse() {
545 config.server.port = port;
546 }
547 }
548
549 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
550 config.server.bind = bind;
551 }
552
553 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
555 config.provider = provider;
556 }
557
558 if let Ok(model) = std::env::var("MODEL") {
559 config.model = Some(model);
560 }
561
562 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
563 config.headless_auth = parse_bool_env(&headless);
564 }
565
566 config
567 }
568
569 pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
575 if self.proxy_auth.is_some() {
576 return;
577 }
578
579 if self
586 .proxy_auth_encrypted
587 .as_deref()
588 .map(|s| s.trim().is_empty())
589 .unwrap_or(true)
590 {
591 let legacy = self
592 .extra
593 .get("https_proxy_auth_encrypted")
594 .and_then(|v| v.as_str())
595 .or_else(|| {
596 self.extra
597 .get("http_proxy_auth_encrypted")
598 .and_then(|v| v.as_str())
599 })
600 .map(|s| s.trim())
601 .filter(|s| !s.is_empty())
602 .map(|s| s.to_string());
603
604 if let Some(legacy) = legacy {
605 self.proxy_auth_encrypted = Some(legacy);
606 }
607 }
608
609 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
610 return;
611 };
612
613 match crate::core::encryption::decrypt(encrypted) {
614 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
615 Ok(auth) => {
616 self.proxy_auth = Some(auth);
617 self.extra.remove("http_proxy_auth_encrypted");
620 self.extra.remove("https_proxy_auth_encrypted");
621 }
622 Err(e) => log::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
623 },
624 Err(e) => log::warn!("Failed to decrypt proxy auth: {}", e),
625 }
626 }
627
628 pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
633 let Some(auth) = self.proxy_auth.as_ref() else {
637 self.proxy_auth_encrypted = None;
638 return Ok(());
639 };
640
641 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
642 let encrypted =
643 crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
644 self.proxy_auth_encrypted = Some(encrypted);
645 Ok(())
646 }
647
648 pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
649 if let Some(openai) = self.providers.openai.as_mut() {
650 if openai.api_key.trim().is_empty() {
651 if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
652 match crate::core::encryption::decrypt(encrypted) {
653 Ok(value) => openai.api_key = value,
654 Err(e) => log::warn!("Failed to decrypt OpenAI api_key: {}", e),
655 }
656 }
657 }
658 }
659
660 if let Some(anthropic) = self.providers.anthropic.as_mut() {
661 if anthropic.api_key.trim().is_empty() {
662 if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
663 match crate::core::encryption::decrypt(encrypted) {
664 Ok(value) => anthropic.api_key = value,
665 Err(e) => log::warn!("Failed to decrypt Anthropic api_key: {}", e),
666 }
667 }
668 }
669 }
670
671 if let Some(gemini) = self.providers.gemini.as_mut() {
672 if gemini.api_key.trim().is_empty() {
673 if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
674 match crate::core::encryption::decrypt(encrypted) {
675 Ok(value) => gemini.api_key = value,
676 Err(e) => log::warn!("Failed to decrypt Gemini api_key: {}", e),
677 }
678 }
679 }
680 }
681 }
682
683 pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
684 if let Some(openai) = self.providers.openai.as_mut() {
685 let api_key = openai.api_key.trim();
686 openai.api_key_encrypted = if api_key.is_empty() {
687 None
688 } else {
689 Some(
690 crate::core::encryption::encrypt(api_key)
691 .context("Failed to encrypt OpenAI api_key")?,
692 )
693 };
694 }
695
696 if let Some(anthropic) = self.providers.anthropic.as_mut() {
697 let api_key = anthropic.api_key.trim();
698 anthropic.api_key_encrypted = if api_key.is_empty() {
699 None
700 } else {
701 Some(
702 crate::core::encryption::encrypt(api_key)
703 .context("Failed to encrypt Anthropic api_key")?,
704 )
705 };
706 }
707
708 if let Some(gemini) = self.providers.gemini.as_mut() {
709 let api_key = gemini.api_key.trim();
710 gemini.api_key_encrypted = if api_key.is_empty() {
711 None
712 } else {
713 Some(
714 crate::core::encryption::encrypt(api_key)
715 .context("Failed to encrypt Gemini api_key")?,
716 )
717 };
718 }
719
720 Ok(())
721 }
722
723 pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
724 for server in self.mcp.servers.iter_mut() {
725 match &mut server.transport {
726 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
727 if stdio.env_encrypted.is_empty() {
728 continue;
729 }
730
731 for (key, encrypted) in stdio.env_encrypted.clone() {
733 let should_hydrate = stdio
734 .env
735 .get(&key)
736 .map(|v| v.trim().is_empty())
737 .unwrap_or(true);
738 if !should_hydrate {
739 continue;
740 }
741
742 match crate::core::encryption::decrypt(&encrypted) {
743 Ok(value) => {
744 stdio.env.insert(key, value);
745 }
746 Err(e) => log::warn!("Failed to decrypt MCP stdio env var: {}", e),
747 }
748 }
749 }
750 crate::agent::mcp::TransportConfig::Sse(sse) => {
751 for header in sse.headers.iter_mut() {
752 if !header.value.trim().is_empty() {
753 continue;
754 }
755 let Some(encrypted) = header.value_encrypted.as_deref() else {
756 continue;
757 };
758 match crate::core::encryption::decrypt(encrypted) {
759 Ok(value) => header.value = value,
760 Err(e) => log::warn!("Failed to decrypt MCP SSE header value: {}", e),
761 }
762 }
763 }
764 }
765 }
766 }
767
768 pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
769 for server in self.mcp.servers.iter_mut() {
770 match &mut server.transport {
771 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
772 stdio.env_encrypted.clear();
773 for (key, value) in &stdio.env {
774 let encrypted =
775 crate::core::encryption::encrypt(value).with_context(|| {
776 format!("Failed to encrypt MCP stdio env var '{key}'")
777 })?;
778 stdio.env_encrypted.insert(key.clone(), encrypted);
779 }
780 }
781 crate::agent::mcp::TransportConfig::Sse(sse) => {
782 for header in sse.headers.iter_mut() {
783 let configured = !header.value.trim().is_empty();
784 header.value_encrypted = if !configured {
785 None
786 } else {
787 Some(
788 crate::core::encryption::encrypt(&header.value).with_context(
789 || {
790 format!(
791 "Failed to encrypt MCP SSE header '{}'",
792 header.name
793 )
794 },
795 )?,
796 )
797 };
798 }
799 }
800 }
801 }
802
803 Ok(())
804 }
805
806 fn create_default() -> Self {
808 Config {
809 http_proxy: String::new(),
810 https_proxy: String::new(),
811 proxy_auth: None,
812 proxy_auth_encrypted: None,
813 model: None,
814 headless_auth: false,
815 provider: default_provider(),
816 providers: ProviderConfigs::default(),
817 server: ServerConfig::default(),
818 keyword_masking: KeywordMaskingConfig::default(),
819 anthropic_model_mapping: AnthropicModelMapping::default(),
820 gemini_model_mapping: GeminiModelMapping::default(),
821 hooks: HooksConfig::default(),
822 mcp: crate::agent::mcp::McpConfig::default(),
823 extra: BTreeMap::new(),
824 }
825 }
826
827 pub fn server_addr(&self) -> String {
829 format!("{}:{}", self.server.bind, self.server.port)
830 }
831
832 pub fn save(&self) -> Result<()> {
834 self.save_to_dir(default_data_dir())
835 }
836
837 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
841 let path = data_dir.join("config.json");
842
843 if let Some(parent) = path.parent() {
844 std::fs::create_dir_all(parent)
845 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
846 }
847
848 let mut to_save = self.clone();
849 to_save.extra.remove("data_dir");
851 to_save.refresh_proxy_auth_encrypted()?;
852 to_save.refresh_provider_api_keys_encrypted()?;
853 let content =
854 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
855 write_atomic(&path, content.as_bytes())
856 .with_context(|| format!("Failed to write config file: {:?}", path))?;
857
858 Ok(())
859 }
860
861 fn migrate_legacy_sidecar_configs(&mut self, data_dir: &std::path::Path) {
862 let mut changed = false;
863
864 if self.keyword_masking.entries.is_empty() {
866 let path = data_dir.join("keyword_masking.json");
867 if path.exists() {
868 match std::fs::read_to_string(&path) {
869 Ok(content) => match serde_json::from_str::<KeywordMaskingConfig>(&content) {
870 Ok(km) => {
871 self.keyword_masking = km;
872 changed = true;
873 let _ = backup_legacy_file(&path);
874 }
875 Err(e) => log::warn!(
876 "Failed to migrate keyword_masking.json into config.json: {}",
877 e
878 ),
879 },
880 Err(e) => {
881 log::warn!("Failed to read keyword_masking.json for migration: {}", e)
882 }
883 }
884 }
885 }
886
887 if self.anthropic_model_mapping.mappings.is_empty() {
889 let path = data_dir.join("anthropic-model-mapping.json");
890 if path.exists() {
891 match std::fs::read_to_string(&path) {
892 Ok(content) => match serde_json::from_str::<AnthropicModelMapping>(&content) {
893 Ok(mapping) => {
894 self.anthropic_model_mapping = mapping;
895 changed = true;
896 let _ = backup_legacy_file(&path);
897 }
898 Err(e) => log::warn!(
899 "Failed to migrate anthropic-model-mapping.json into config.json: {}",
900 e
901 ),
902 },
903 Err(e) => log::warn!(
904 "Failed to read anthropic-model-mapping.json for migration: {}",
905 e
906 ),
907 }
908 }
909 }
910
911 if self.gemini_model_mapping.mappings.is_empty() {
913 let path = data_dir.join("gemini-model-mapping.json");
914 if path.exists() {
915 match std::fs::read_to_string(&path) {
916 Ok(content) => match serde_json::from_str::<GeminiModelMapping>(&content) {
917 Ok(mapping) => {
918 self.gemini_model_mapping = mapping;
919 changed = true;
920 let _ = backup_legacy_file(&path);
921 }
922 Err(e) => log::warn!(
923 "Failed to migrate gemini-model-mapping.json into config.json: {}",
924 e
925 ),
926 },
927 Err(e) => log::warn!(
928 "Failed to read gemini-model-mapping.json for migration: {}",
929 e
930 ),
931 }
932 }
933 }
934
935 if self.mcp.servers.is_empty() {
937 let path = data_dir.join("mcp.json");
938 if path.exists() {
939 match std::fs::read_to_string(&path) {
940 Ok(content) => {
941 match serde_json::from_str::<crate::agent::mcp::McpConfig>(&content) {
942 Ok(mcp) => {
943 self.mcp = mcp;
944 changed = true;
945 let _ = backup_legacy_file(&path);
946 }
947 Err(e) => {
948 log::warn!("Failed to migrate mcp.json into config.json: {}", e)
949 }
950 }
951 }
952 Err(e) => log::warn!("Failed to read mcp.json for migration: {}", e),
953 }
954 }
955 }
956
957 if !self.extra.contains_key("permissions") {
959 let path = data_dir.join("permissions.json");
960 if path.exists() {
961 match std::fs::read_to_string(&path) {
962 Ok(content) => match serde_json::from_str::<Value>(&content) {
963 Ok(value) => {
964 self.extra.insert("permissions".to_string(), value);
965 changed = true;
966 let _ = backup_legacy_file(&path);
967 }
968 Err(e) => {
969 log::warn!("Failed to migrate permissions.json into config.json: {}", e)
970 }
971 },
972 Err(e) => log::warn!("Failed to read permissions.json for migration: {}", e),
973 }
974 }
975 }
976
977 if changed {
978 if let Err(e) = self.save_to_dir(data_dir.to_path_buf()) {
979 log::warn!(
980 "Failed to persist unified config.json after migration: {}",
981 e
982 );
983 }
984 }
985 }
986}
987
988fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
989 let Some(parent) = path.parent() else {
990 return std::fs::write(path, content);
991 };
992
993 std::fs::create_dir_all(parent)?;
994
995 let file_name = path
998 .file_name()
999 .and_then(|s| s.to_str())
1000 .unwrap_or("config.json");
1001 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1002 let tmp_path = parent.join(tmp_name);
1003
1004 {
1005 let mut file = std::fs::File::create(&tmp_path)?;
1006 file.write_all(content)?;
1007 file.sync_all()?;
1008 }
1009
1010 std::fs::rename(&tmp_path, path)?;
1011 Ok(())
1012}
1013
1014fn backup_legacy_file(path: &std::path::Path) -> std::io::Result<()> {
1015 let Some(parent) = path.parent() else {
1016 return Ok(());
1017 };
1018 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
1019 return Ok(());
1020 };
1021 let backup = parent.join(format!("{name}.migrated.bak"));
1022 if backup.exists() {
1023 return Ok(());
1024 }
1025 std::fs::rename(path, backup)?;
1026 Ok(())
1027}
1028
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1034struct OldConfig {
1035 #[serde(default)]
1036 http_proxy: String,
1037 #[serde(default)]
1038 https_proxy: String,
1039 #[serde(default)]
1040 http_proxy_auth: Option<ProxyAuth>,
1041 #[serde(default)]
1042 https_proxy_auth: Option<ProxyAuth>,
1043 api_key: Option<String>,
1044 api_base: Option<String>,
1045 model: Option<String>,
1046 #[serde(default)]
1047 headless_auth: bool,
1048 #[serde(default = "default_provider")]
1050 provider: String,
1051 #[serde(default)]
1052 server: ServerConfig,
1053 #[serde(default)]
1054 providers: ProviderConfigs,
1055 #[serde(default)]
1056 data_dir: Option<PathBuf>,
1057
1058 #[serde(default)]
1059 keyword_masking: KeywordMaskingConfig,
1060 #[serde(default)]
1061 anthropic_model_mapping: AnthropicModelMapping,
1062 #[serde(default)]
1063 gemini_model_mapping: GeminiModelMapping,
1064 #[serde(default)]
1065 mcp: crate::agent::mcp::McpConfig,
1066
1067 #[serde(default, flatten)]
1069 extra: BTreeMap<String, Value>,
1070}
1071
1072fn migrate_config(old: OldConfig) -> Config {
1077 if old.api_key.is_some() {
1079 log::warn!(
1080 "api_key is no longer used. CopilotClient automatically manages authentication."
1081 );
1082 }
1083 if old.api_base.is_some() {
1084 log::warn!(
1085 "api_base is no longer used. CopilotClient automatically manages API endpoints."
1086 );
1087 }
1088
1089 let proxy_auth = old.https_proxy_auth.or(old.http_proxy_auth);
1090 let proxy_auth_encrypted = proxy_auth
1091 .as_ref()
1092 .and_then(|auth| serde_json::to_string(auth).ok())
1093 .and_then(|auth_str| crate::core::encryption::encrypt(&auth_str).ok());
1094
1095 Config {
1096 http_proxy: old.http_proxy,
1097 https_proxy: old.https_proxy,
1098 proxy_auth,
1100 proxy_auth_encrypted,
1101 model: old.model,
1102 headless_auth: old.headless_auth,
1103 provider: old.provider,
1104 providers: old.providers,
1105 server: old.server,
1106 keyword_masking: old.keyword_masking,
1107 anthropic_model_mapping: old.anthropic_model_mapping,
1108 gemini_model_mapping: old.gemini_model_mapping,
1109 hooks: HooksConfig::default(),
1110 mcp: old.mcp,
1111 extra: old.extra,
1112 }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118 use std::ffi::OsString;
1119 use std::path::PathBuf;
1120 use std::sync::{Mutex, OnceLock};
1121 use std::time::{SystemTime, UNIX_EPOCH};
1122
1123 struct EnvVarGuard {
1124 key: &'static str,
1125 previous: Option<OsString>,
1126 }
1127
1128 impl EnvVarGuard {
1129 fn set(key: &'static str, value: &str) -> Self {
1130 let previous = std::env::var_os(key);
1131 std::env::set_var(key, value);
1132 Self { key, previous }
1133 }
1134
1135 fn unset(key: &'static str) -> Self {
1136 let previous = std::env::var_os(key);
1137 std::env::remove_var(key);
1138 Self { key, previous }
1139 }
1140 }
1141
1142 impl Drop for EnvVarGuard {
1143 fn drop(&mut self) {
1144 match &self.previous {
1145 Some(value) => std::env::set_var(self.key, value),
1146 None => std::env::remove_var(self.key),
1147 }
1148 }
1149 }
1150
1151 struct TempHome {
1152 path: PathBuf,
1153 }
1154
1155 impl TempHome {
1156 fn new() -> Self {
1157 let nanos = SystemTime::now()
1158 .duration_since(UNIX_EPOCH)
1159 .expect("clock should be after unix epoch")
1160 .as_nanos();
1161 let path = std::env::temp_dir().join(format!(
1162 "chat-core-config-test-{}-{}",
1163 std::process::id(),
1164 nanos
1165 ));
1166 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1167 Self { path }
1168 }
1169
1170 fn set_config_json(&self, content: &str) {
1171 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1174 std::fs::write(self.path.join("config.json"), content)
1175 .expect("failed to write config.json");
1176 }
1177 }
1178
1179 impl Drop for TempHome {
1180 fn drop(&mut self) {
1181 let _ = std::fs::remove_dir_all(&self.path);
1182 }
1183 }
1184
1185 fn env_lock() -> &'static Mutex<()> {
1186 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1187 LOCK.get_or_init(|| Mutex::new(()))
1188 }
1189
1190 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1192 env_lock().lock().unwrap_or_else(|poisoned| {
1193 poisoned.into_inner()
1195 })
1196 }
1197
1198 #[test]
1199 fn parse_bool_env_true_values() {
1200 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1201 assert!(parse_bool_env(value), "value {value:?} should be true");
1202 }
1203 }
1204
1205 #[test]
1206 fn parse_bool_env_false_values() {
1207 for value in ["0", "false", "no", "off", "", " "] {
1208 assert!(!parse_bool_env(value), "value {value:?} should be false");
1209 }
1210 }
1211
1212 #[test]
1213 fn config_new_ignores_http_proxy_env_vars() {
1214 let _lock = env_lock_acquire();
1215 let temp_home = TempHome::new();
1216 temp_home.set_config_json(
1217 r#"{
1218 "http_proxy": "",
1219 "https_proxy": ""
1220}"#,
1221 );
1222
1223 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1224 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1225
1226 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1227
1228 assert!(
1229 config.http_proxy.is_empty(),
1230 "config should ignore HTTP_PROXY env var"
1231 );
1232 assert!(
1233 config.https_proxy.is_empty(),
1234 "config should ignore HTTPS_PROXY env var"
1235 );
1236 }
1237
1238 #[test]
1239 fn config_new_loads_config_when_proxy_fields_omitted() {
1240 let _lock = env_lock_acquire();
1241 let temp_home = TempHome::new();
1242 temp_home.set_config_json(
1243 r#"{
1244 "model": "gpt-4"
1245}"#,
1246 );
1247
1248 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1249 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1250
1251 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1252
1253 assert_eq!(
1254 config.model.as_deref(),
1255 Some("gpt-4"),
1256 "config should load model from config file even when proxy fields are omitted"
1257 );
1258 assert!(config.http_proxy.is_empty());
1259 assert!(config.https_proxy.is_empty());
1260 }
1261
1262 #[test]
1263 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
1264 let _lock = env_lock_acquire();
1265 let temp_home = TempHome::new();
1266 temp_home.set_config_json(
1267 r#"{
1268 "model": "gpt-4"
1269}"#,
1270 );
1271
1272 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1273 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1274
1275 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1276
1277 assert_eq!(config.model.as_deref(), Some("gpt-4"));
1278 assert!(
1279 config.http_proxy.is_empty(),
1280 "config should keep http_proxy empty when field is omitted"
1281 );
1282 assert!(
1283 config.https_proxy.is_empty(),
1284 "config should keep https_proxy empty when field is omitted"
1285 );
1286 }
1287
1288 #[test]
1289 fn config_migrates_old_format_to_new() {
1290 let _lock = env_lock_acquire();
1291 let temp_home = TempHome::new();
1292
1293 temp_home.set_config_json(
1295 r#"{
1296 "http_proxy": "http://proxy.example.com:8080",
1297 "https_proxy": "http://proxy.example.com:8443",
1298 "http_proxy_auth": {
1299 "username": "http_user",
1300 "password": "http_pass"
1301 },
1302 "https_proxy_auth": {
1303 "username": "https_user",
1304 "password": "https_pass"
1305 },
1306 "api_key": "old_key",
1307 "api_base": "https://old.api.com",
1308 "model": "gpt-4",
1309 "headless_auth": true
1310}"#,
1311 );
1312
1313 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1314
1315 assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
1317 assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
1318
1319 assert!(config.proxy_auth.is_some());
1321 let auth = config.proxy_auth.unwrap();
1322 assert_eq!(auth.username, "https_user");
1323 assert_eq!(auth.password, "https_pass");
1324
1325 assert_eq!(config.model.as_deref(), Some("gpt-4"));
1327 assert!(config.headless_auth);
1328
1329 }
1331
1332 #[test]
1333 fn config_migrates_only_http_proxy_auth() {
1334 let _lock = env_lock_acquire();
1335 let temp_home = TempHome::new();
1336
1337 temp_home.set_config_json(
1339 r#"{
1340 "http_proxy": "http://proxy.example.com:8080",
1341 "http_proxy_auth": {
1342 "username": "http_user",
1343 "password": "http_pass"
1344 }
1345}"#,
1346 );
1347
1348 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1349
1350 assert!(
1352 config.proxy_auth.is_some(),
1353 "proxy_auth should be migrated from http_proxy_auth"
1354 );
1355 let auth = config.proxy_auth.unwrap();
1356 assert_eq!(auth.username, "http_user");
1357 assert_eq!(auth.password, "http_pass");
1358 }
1359
1360 #[test]
1361 fn test_server_config_defaults() {
1362 let _lock = env_lock_acquire();
1363 let temp_home = TempHome::new();
1364
1365 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1366 assert_eq!(config.server.port, 8080);
1367 assert_eq!(config.server.bind, "127.0.0.1");
1368 assert_eq!(config.server.workers, 10);
1369 assert!(config.server.static_dir.is_none());
1370 }
1371
1372 #[test]
1373 fn test_server_addr() {
1374 let mut config = Config::default();
1375 config.server.port = 9000;
1376 config.server.bind = "0.0.0.0".to_string();
1377 assert_eq!(config.server_addr(), "0.0.0.0:9000");
1378 }
1379
1380 #[test]
1381 fn test_env_var_overrides() {
1382 let _lock = env_lock_acquire();
1383 let temp_home = TempHome::new();
1384
1385 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1386 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1387 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1388
1389 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1390 assert_eq!(config.server.port, 9999);
1391 assert_eq!(config.server.bind, "192.168.1.1");
1392 assert_eq!(config.provider, "openai");
1393 }
1394
1395 #[test]
1396 fn test_config_save_and_load() {
1397 let _lock = env_lock_acquire();
1398 let temp_home = TempHome::new();
1399
1400 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1401 config.server.port = 9000;
1402 config.server.bind = "0.0.0.0".to_string();
1403 config.provider = "anthropic".to_string();
1404
1405 config
1407 .save_to_dir(temp_home.path.clone())
1408 .expect("Failed to save config");
1409
1410 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1412
1413 assert_eq!(loaded.server.port, 9000);
1415 assert_eq!(loaded.server.bind, "0.0.0.0");
1416 assert_eq!(loaded.provider, "anthropic");
1417 }
1418
1419 #[test]
1420 fn config_decrypts_proxy_auth_from_encrypted_field() {
1421 let _lock = env_lock_acquire();
1422 let temp_home = TempHome::new();
1423
1424 let key_guard = crate::core::encryption::set_test_encryption_key([
1426 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1427 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1428 0x1c, 0x1d, 0x1e, 0x1f,
1429 ]);
1430
1431 let auth = ProxyAuth {
1432 username: "user".to_string(),
1433 password: "pass".to_string(),
1434 };
1435 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1436 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1437
1438 temp_home.set_config_json(&format!(
1439 r#"{{
1440 "http_proxy": "http://proxy.example.com:8080",
1441 "proxy_auth_encrypted": "{encrypted}"
1442}}"#
1443 ));
1444 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1445 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1446 assert_eq!(loaded_auth.username, "user");
1447 assert_eq!(loaded_auth.password, "pass");
1448 drop(key_guard);
1449 }
1450
1451 #[test]
1452 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
1453 let _lock = env_lock_acquire();
1454 let temp_home = TempHome::new();
1455
1456 let key_guard = crate::core::encryption::set_test_encryption_key([
1458 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1459 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1460 0x1c, 0x1d, 0x1e, 0x1f,
1461 ]);
1462
1463 let auth = ProxyAuth {
1464 username: "user".to_string(),
1465 password: "pass".to_string(),
1466 };
1467 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1468 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1469
1470 temp_home.set_config_json(&format!(
1472 r#"{{
1473 "http_proxy": "http://proxy.example.com:8080",
1474 "http_proxy_auth_encrypted": "{encrypted}",
1475 "https_proxy_auth_encrypted": "{encrypted}"
1476}}"#
1477 ));
1478
1479 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1480 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1481 assert_eq!(loaded_auth.username, "user");
1482 assert_eq!(loaded_auth.password, "pass");
1483 drop(key_guard);
1484 }
1485
1486 #[test]
1487 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1488 let _lock = env_lock_acquire();
1489 let temp_home = TempHome::new();
1490
1491 let key_guard = crate::core::encryption::set_test_encryption_key([
1493 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1494 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1495 0x1c, 0x1d, 0x1e, 0x1f,
1496 ]);
1497
1498 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1499 config.proxy_auth = Some(ProxyAuth {
1500 username: "user".to_string(),
1501 password: "pass".to_string(),
1502 });
1503 config
1504 .save_to_dir(temp_home.path.clone())
1505 .expect("save should encrypt proxy auth");
1506
1507 let content =
1508 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1509 assert!(
1510 content.contains("proxy_auth_encrypted"),
1511 "config.json should store encrypted proxy auth"
1512 );
1513 assert!(
1514 !content.contains("\"proxy_auth\""),
1515 "config.json should not store plaintext proxy_auth"
1516 );
1517
1518 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1519 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1520 assert_eq!(loaded_auth.username, "user");
1521 assert_eq!(loaded_auth.password, "pass");
1522 drop(key_guard);
1523 }
1524
1525 #[test]
1526 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
1527 let _lock = env_lock_acquire();
1528 let temp_home = TempHome::new();
1529
1530 let key_guard = crate::core::encryption::set_test_encryption_key([
1532 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1533 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1534 0x1c, 0x1d, 0x1e, 0x1f,
1535 ]);
1536
1537 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1538 config.provider = "openai".to_string();
1539 config.providers.openai = Some(OpenAIConfig {
1540 api_key: "sk-test-provider-key".to_string(),
1541 api_key_encrypted: None,
1542 base_url: None,
1543 model: None,
1544 responses_only_models: vec![],
1545 extra: Default::default(),
1546 });
1547
1548 config
1549 .save_to_dir(temp_home.path.clone())
1550 .expect("save should encrypt provider api keys");
1551
1552 let content =
1553 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1554 assert!(
1555 content.contains("\"api_key_encrypted\""),
1556 "config.json should store encrypted provider keys"
1557 );
1558 assert!(
1559 !content.contains("\"api_key\""),
1560 "config.json should not store plaintext provider keys"
1561 );
1562
1563 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1564 let openai = loaded
1565 .providers
1566 .openai
1567 .expect("openai config should be present");
1568 assert_eq!(openai.api_key, "sk-test-provider-key");
1569
1570 drop(key_guard);
1571 }
1572
1573 #[test]
1574 fn config_save_persists_mcp_servers_in_mainstream_format() {
1575 let _lock = env_lock_acquire();
1576 let temp_home = TempHome::new();
1577
1578 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1579
1580 let mut env = std::collections::HashMap::new();
1581 env.insert("TOKEN".to_string(), "supersecret".to_string());
1582
1583 config.mcp.servers = vec![
1584 crate::agent::mcp::McpServerConfig {
1585 id: "stdio-secret".to_string(),
1586 name: None,
1587 enabled: true,
1588 transport: crate::agent::mcp::TransportConfig::Stdio(
1589 crate::agent::mcp::StdioConfig {
1590 command: "echo".to_string(),
1591 args: vec![],
1592 cwd: None,
1593 env,
1594 env_encrypted: std::collections::HashMap::new(),
1595 startup_timeout_ms: 5000,
1596 },
1597 ),
1598 request_timeout_ms: 5000,
1599 healthcheck_interval_ms: 1000,
1600 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1601 allowed_tools: vec![],
1602 denied_tools: vec![],
1603 },
1604 crate::agent::mcp::McpServerConfig {
1605 id: "sse-secret".to_string(),
1606 name: None,
1607 enabled: true,
1608 transport: crate::agent::mcp::TransportConfig::Sse(crate::agent::mcp::SseConfig {
1609 url: "http://localhost:8080/sse".to_string(),
1610 headers: vec![crate::agent::mcp::HeaderConfig {
1611 name: "Authorization".to_string(),
1612 value: "Bearer token123".to_string(),
1613 value_encrypted: None,
1614 }],
1615 connect_timeout_ms: 5000,
1616 }),
1617 request_timeout_ms: 5000,
1618 healthcheck_interval_ms: 1000,
1619 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1620 allowed_tools: vec![],
1621 denied_tools: vec![],
1622 },
1623 ];
1624
1625 config
1626 .save_to_dir(temp_home.path.clone())
1627 .expect("save should persist MCP servers");
1628
1629 let content =
1630 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1631 assert!(
1632 content.contains("\"mcpServers\""),
1633 "config.json should store MCP servers under the mainstream 'mcpServers' key"
1634 );
1635 assert!(
1636 content.contains("supersecret"),
1637 "config.json should persist MCP stdio env in mainstream format"
1638 );
1639 assert!(
1640 content.contains("Bearer token123"),
1641 "config.json should persist MCP SSE headers in mainstream format"
1642 );
1643 assert!(
1644 !content.contains("\"env_encrypted\""),
1645 "config.json should not persist legacy env_encrypted fields"
1646 );
1647 assert!(
1648 !content.contains("\"value_encrypted\""),
1649 "config.json should not persist legacy value_encrypted fields"
1650 );
1651
1652 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1653 let stdio = loaded
1654 .mcp
1655 .servers
1656 .iter()
1657 .find(|s| s.id == "stdio-secret")
1658 .expect("stdio server should exist");
1659 match &stdio.transport {
1660 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1661 assert_eq!(
1662 stdio.env.get("TOKEN").map(|s| s.as_str()),
1663 Some("supersecret")
1664 );
1665 }
1666 _ => panic!("Expected stdio transport"),
1667 }
1668
1669 let sse = loaded
1670 .mcp
1671 .servers
1672 .iter()
1673 .find(|s| s.id == "sse-secret")
1674 .expect("sse server should exist");
1675 match &sse.transport {
1676 crate::agent::mcp::TransportConfig::Sse(sse) => {
1677 assert_eq!(sse.headers[0].value, "Bearer token123");
1678 }
1679 _ => panic!("Expected SSE transport"),
1680 }
1681 }
1682}