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