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 = "default_data_dir")]
104 pub data_dir: PathBuf,
105
106 #[serde(default)]
110 pub keyword_masking: KeywordMaskingConfig,
111
112 #[serde(default)]
116 pub anthropic_model_mapping: AnthropicModelMapping,
117
118 #[serde(default)]
122 pub gemini_model_mapping: GeminiModelMapping,
123
124 #[serde(default)]
128 pub mcp: crate::agent::mcp::McpConfig,
129
130 #[serde(default, flatten)]
136 pub extra: BTreeMap<String, Value>,
137}
138
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct ProviderConfigs {
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub openai: Option<OpenAIConfig>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub anthropic: Option<AnthropicConfig>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub gemini: Option<GeminiConfig>,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub copilot: Option<CopilotConfig>,
156
157 #[serde(default, flatten)]
159 pub extra: BTreeMap<String, Value>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct OpenAIConfig {
175 #[serde(default, skip_serializing)]
179 pub api_key: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub api_key_encrypted: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub base_url: Option<String>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub model: Option<String>,
189
190 #[serde(default, flatten)]
192 pub extra: BTreeMap<String, Value>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct AnthropicConfig {
208 #[serde(default, skip_serializing)]
212 pub api_key: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub api_key_encrypted: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub base_url: Option<String>,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub model: Option<String>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub max_tokens: Option<u32>,
225
226 #[serde(default, flatten)]
228 pub extra: BTreeMap<String, Value>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct GeminiConfig {
243 #[serde(default, skip_serializing)]
247 pub api_key: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub api_key_encrypted: Option<String>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub base_url: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub model: Option<String>,
257
258 #[serde(default, flatten)]
260 pub extra: BTreeMap<String, Value>,
261}
262
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct CopilotConfig {
276 #[serde(default)]
278 pub enabled: bool,
279 #[serde(default)]
281 pub headless_auth: bool,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub model: Option<String>,
285
286 #[serde(default, flatten)]
288 pub extra: BTreeMap<String, Value>,
289}
290
291fn default_provider() -> String {
293 "anthropic".to_string()
294}
295
296fn default_port() -> u16 {
298 8080
299}
300
301fn default_bind() -> String {
303 "127.0.0.1".to_string()
304}
305
306fn default_workers() -> usize {
308 10
309}
310
311fn default_data_dir() -> PathBuf {
313 super::paths::bamboo_dir()
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ServerConfig {
319 #[serde(default = "default_port")]
321 pub port: u16,
322
323 #[serde(default = "default_bind")]
325 pub bind: String,
326
327 pub static_dir: Option<PathBuf>,
329
330 #[serde(default = "default_workers")]
332 pub workers: usize,
333
334 #[serde(default, flatten)]
336 pub extra: BTreeMap<String, Value>,
337}
338
339impl Default for ServerConfig {
340 fn default() -> Self {
341 Self {
342 port: default_port(),
343 bind: default_bind(),
344 static_dir: None,
345 workers: default_workers(),
346 extra: BTreeMap::new(),
347 }
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ProxyAuth {
354 pub username: String,
356 pub password: String,
358}
359
360const CONFIG_FILE_PATH: &str = "config.toml";
362
363fn parse_bool_env(value: &str) -> bool {
367 matches!(
368 value.trim().to_ascii_lowercase().as_str(),
369 "1" | "true" | "yes" | "y" | "on"
370 )
371}
372
373impl Default for Config {
374 fn default() -> Self {
375 Self::new()
376 }
377}
378
379impl Config {
380 pub fn new() -> Self {
398 Self::from_data_dir(None)
399 }
400
401 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
407 let data_dir = data_dir
409 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
410 .unwrap_or_else(default_data_dir);
411
412 let config_path = data_dir.join("config.json");
413
414 let mut config = if config_path.exists() {
415 if let Ok(content) = std::fs::read_to_string(&config_path) {
416 if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
418 let has_old_fields = old_config.http_proxy_auth.is_some()
420 || old_config.https_proxy_auth.is_some()
421 || old_config.api_key.is_some()
422 || old_config.api_base.is_some();
423
424 if has_old_fields {
425 log::info!("Migrating old config format to new format");
426 let migrated = migrate_config(old_config);
427 if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
429 let _ = std::fs::write(&config_path, new_content);
430 }
431 migrated
432 } else {
433 match serde_json::from_str::<Config>(&content) {
437 Ok(mut config) => {
438 config.hydrate_proxy_auth_from_encrypted();
439 config.hydrate_provider_api_keys_from_encrypted();
440 config.hydrate_mcp_secrets_from_encrypted();
441 config
442 }
443 Err(_) => {
444 migrate_config(old_config)
446 }
447 }
448 }
449 } else {
450 serde_json::from_str::<Config>(&content)
452 .map(|mut config| {
453 config.hydrate_proxy_auth_from_encrypted();
454 config.hydrate_provider_api_keys_from_encrypted();
455 config.hydrate_mcp_secrets_from_encrypted();
456 config
457 })
458 .unwrap_or_else(|_| Self::create_default())
459 }
460 } else {
461 Self::create_default()
462 }
463 } else {
464 if std::path::Path::new(CONFIG_FILE_PATH).exists() {
466 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_PATH) {
467 if let Ok(old_config) = toml::from_str::<OldConfig>(&content) {
468 migrate_config(old_config)
469 } else {
470 Self::create_default()
471 }
472 } else {
473 Self::create_default()
474 }
475 } else {
476 Self::create_default()
477 }
478 };
479
480 config.data_dir = data_dir;
482 config.hydrate_proxy_auth_from_encrypted();
484 config.hydrate_provider_api_keys_from_encrypted();
486 config.hydrate_mcp_secrets_from_encrypted();
488
489 config.migrate_legacy_sidecar_configs();
492
493 if let Ok(port) = std::env::var("BAMBOO_PORT") {
495 if let Ok(port) = port.parse() {
496 config.server.port = port;
497 }
498 }
499
500 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
501 config.server.bind = bind;
502 }
503
504 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
506 config.provider = provider;
507 }
508
509 if let Ok(model) = std::env::var("MODEL") {
510 config.model = Some(model);
511 }
512
513 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
514 config.headless_auth = parse_bool_env(&headless);
515 }
516
517 config
518 }
519
520 pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
526 if self.proxy_auth.is_some() {
527 return;
528 }
529
530 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
531 return;
532 };
533
534 match crate::core::encryption::decrypt(encrypted) {
535 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
536 Ok(auth) => self.proxy_auth = Some(auth),
537 Err(e) => log::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
538 },
539 Err(e) => log::warn!("Failed to decrypt proxy auth: {}", e),
540 }
541 }
542
543 pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
548 let Some(auth) = self.proxy_auth.as_ref() else {
552 self.proxy_auth_encrypted = None;
553 return Ok(());
554 };
555
556 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
557 let encrypted =
558 crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
559 self.proxy_auth_encrypted = Some(encrypted);
560 Ok(())
561 }
562
563 pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
564 if let Some(openai) = self.providers.openai.as_mut() {
565 if openai.api_key.trim().is_empty() {
566 if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
567 match crate::core::encryption::decrypt(encrypted) {
568 Ok(value) => openai.api_key = value,
569 Err(e) => log::warn!("Failed to decrypt OpenAI api_key: {}", e),
570 }
571 }
572 }
573 }
574
575 if let Some(anthropic) = self.providers.anthropic.as_mut() {
576 if anthropic.api_key.trim().is_empty() {
577 if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
578 match crate::core::encryption::decrypt(encrypted) {
579 Ok(value) => anthropic.api_key = value,
580 Err(e) => log::warn!("Failed to decrypt Anthropic api_key: {}", e),
581 }
582 }
583 }
584 }
585
586 if let Some(gemini) = self.providers.gemini.as_mut() {
587 if gemini.api_key.trim().is_empty() {
588 if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
589 match crate::core::encryption::decrypt(encrypted) {
590 Ok(value) => gemini.api_key = value,
591 Err(e) => log::warn!("Failed to decrypt Gemini api_key: {}", e),
592 }
593 }
594 }
595 }
596 }
597
598 pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
599 if let Some(openai) = self.providers.openai.as_mut() {
600 let api_key = openai.api_key.trim();
601 openai.api_key_encrypted = if api_key.is_empty() {
602 None
603 } else {
604 Some(
605 crate::core::encryption::encrypt(api_key)
606 .context("Failed to encrypt OpenAI api_key")?,
607 )
608 };
609 }
610
611 if let Some(anthropic) = self.providers.anthropic.as_mut() {
612 let api_key = anthropic.api_key.trim();
613 anthropic.api_key_encrypted = if api_key.is_empty() {
614 None
615 } else {
616 Some(
617 crate::core::encryption::encrypt(api_key)
618 .context("Failed to encrypt Anthropic api_key")?,
619 )
620 };
621 }
622
623 if let Some(gemini) = self.providers.gemini.as_mut() {
624 let api_key = gemini.api_key.trim();
625 gemini.api_key_encrypted = if api_key.is_empty() {
626 None
627 } else {
628 Some(
629 crate::core::encryption::encrypt(api_key)
630 .context("Failed to encrypt Gemini api_key")?,
631 )
632 };
633 }
634
635 Ok(())
636 }
637
638 pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
639 for server in self.mcp.servers.iter_mut() {
640 match &mut server.transport {
641 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
642 if stdio.env_encrypted.is_empty() {
643 continue;
644 }
645
646 for (key, encrypted) in stdio.env_encrypted.clone() {
648 let should_hydrate = stdio
649 .env
650 .get(&key)
651 .map(|v| v.trim().is_empty())
652 .unwrap_or(true);
653 if !should_hydrate {
654 continue;
655 }
656
657 match crate::core::encryption::decrypt(&encrypted) {
658 Ok(value) => {
659 stdio.env.insert(key, value);
660 }
661 Err(e) => log::warn!("Failed to decrypt MCP stdio env var: {}", e),
662 }
663 }
664 }
665 crate::agent::mcp::TransportConfig::Sse(sse) => {
666 for header in sse.headers.iter_mut() {
667 if !header.value.trim().is_empty() {
668 continue;
669 }
670 let Some(encrypted) = header.value_encrypted.as_deref() else {
671 continue;
672 };
673 match crate::core::encryption::decrypt(encrypted) {
674 Ok(value) => header.value = value,
675 Err(e) => log::warn!("Failed to decrypt MCP SSE header value: {}", e),
676 }
677 }
678 }
679 }
680 }
681 }
682
683 pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
684 for server in self.mcp.servers.iter_mut() {
685 match &mut server.transport {
686 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
687 stdio.env_encrypted.clear();
688 for (key, value) in &stdio.env {
689 let encrypted =
690 crate::core::encryption::encrypt(value).with_context(|| {
691 format!("Failed to encrypt MCP stdio env var '{key}'")
692 })?;
693 stdio.env_encrypted.insert(key.clone(), encrypted);
694 }
695 }
696 crate::agent::mcp::TransportConfig::Sse(sse) => {
697 for header in sse.headers.iter_mut() {
698 let configured = !header.value.trim().is_empty();
699 header.value_encrypted = if !configured {
700 None
701 } else {
702 Some(
703 crate::core::encryption::encrypt(&header.value).with_context(
704 || {
705 format!(
706 "Failed to encrypt MCP SSE header '{}'",
707 header.name
708 )
709 },
710 )?,
711 )
712 };
713 }
714 }
715 }
716 }
717
718 Ok(())
719 }
720
721 fn create_default() -> Self {
723 Config {
724 http_proxy: String::new(),
725 https_proxy: String::new(),
726 proxy_auth: None,
727 proxy_auth_encrypted: None,
728 model: None,
729 headless_auth: false,
730 provider: default_provider(),
731 providers: ProviderConfigs::default(),
732 server: ServerConfig::default(),
733 data_dir: default_data_dir(),
734 keyword_masking: KeywordMaskingConfig::default(),
735 anthropic_model_mapping: AnthropicModelMapping::default(),
736 gemini_model_mapping: GeminiModelMapping::default(),
737 mcp: crate::agent::mcp::McpConfig::default(),
738 extra: BTreeMap::new(),
739 }
740 }
741
742 pub fn server_addr(&self) -> String {
744 format!("{}:{}", self.server.bind, self.server.port)
745 }
746
747 pub fn save(&self) -> Result<()> {
749 let path = self.data_dir.join("config.json");
750
751 if let Some(parent) = path.parent() {
752 std::fs::create_dir_all(parent)
753 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
754 }
755
756 let mut to_save = self.clone();
757 to_save.refresh_proxy_auth_encrypted()?;
758 to_save.refresh_provider_api_keys_encrypted()?;
759 to_save.refresh_mcp_secrets_encrypted()?;
760 let content =
761 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
762 write_atomic(&path, content.as_bytes())
763 .with_context(|| format!("Failed to write config file: {:?}", path))?;
764
765 Ok(())
766 }
767
768 fn migrate_legacy_sidecar_configs(&mut self) {
769 let data_dir = self.data_dir.clone();
770 let mut changed = false;
771
772 if self.keyword_masking.entries.is_empty() {
774 let path = data_dir.join("keyword_masking.json");
775 if path.exists() {
776 match std::fs::read_to_string(&path) {
777 Ok(content) => match serde_json::from_str::<KeywordMaskingConfig>(&content) {
778 Ok(km) => {
779 self.keyword_masking = km;
780 changed = true;
781 let _ = backup_legacy_file(&path);
782 }
783 Err(e) => log::warn!(
784 "Failed to migrate keyword_masking.json into config.json: {}",
785 e
786 ),
787 },
788 Err(e) => {
789 log::warn!("Failed to read keyword_masking.json for migration: {}", e)
790 }
791 }
792 }
793 }
794
795 if self.anthropic_model_mapping.mappings.is_empty() {
797 let path = data_dir.join("anthropic-model-mapping.json");
798 if path.exists() {
799 match std::fs::read_to_string(&path) {
800 Ok(content) => match serde_json::from_str::<AnthropicModelMapping>(&content) {
801 Ok(mapping) => {
802 self.anthropic_model_mapping = mapping;
803 changed = true;
804 let _ = backup_legacy_file(&path);
805 }
806 Err(e) => log::warn!(
807 "Failed to migrate anthropic-model-mapping.json into config.json: {}",
808 e
809 ),
810 },
811 Err(e) => log::warn!(
812 "Failed to read anthropic-model-mapping.json for migration: {}",
813 e
814 ),
815 }
816 }
817 }
818
819 if self.gemini_model_mapping.mappings.is_empty() {
821 let path = data_dir.join("gemini-model-mapping.json");
822 if path.exists() {
823 match std::fs::read_to_string(&path) {
824 Ok(content) => match serde_json::from_str::<GeminiModelMapping>(&content) {
825 Ok(mapping) => {
826 self.gemini_model_mapping = mapping;
827 changed = true;
828 let _ = backup_legacy_file(&path);
829 }
830 Err(e) => log::warn!(
831 "Failed to migrate gemini-model-mapping.json into config.json: {}",
832 e
833 ),
834 },
835 Err(e) => log::warn!(
836 "Failed to read gemini-model-mapping.json for migration: {}",
837 e
838 ),
839 }
840 }
841 }
842
843 if self.mcp.servers.is_empty() {
845 let path = data_dir.join("mcp.json");
846 if path.exists() {
847 match std::fs::read_to_string(&path) {
848 Ok(content) => {
849 match serde_json::from_str::<crate::agent::mcp::McpConfig>(&content) {
850 Ok(mcp) => {
851 self.mcp = mcp;
852 changed = true;
853 let _ = backup_legacy_file(&path);
854 }
855 Err(e) => {
856 log::warn!("Failed to migrate mcp.json into config.json: {}", e)
857 }
858 }
859 }
860 Err(e) => log::warn!("Failed to read mcp.json for migration: {}", e),
861 }
862 }
863 }
864
865 if !self.extra.contains_key("permissions") {
867 let path = data_dir.join("permissions.json");
868 if path.exists() {
869 match std::fs::read_to_string(&path) {
870 Ok(content) => match serde_json::from_str::<Value>(&content) {
871 Ok(value) => {
872 self.extra.insert("permissions".to_string(), value);
873 changed = true;
874 let _ = backup_legacy_file(&path);
875 }
876 Err(e) => {
877 log::warn!("Failed to migrate permissions.json into config.json: {}", e)
878 }
879 },
880 Err(e) => log::warn!("Failed to read permissions.json for migration: {}", e),
881 }
882 }
883 }
884
885 if changed {
886 if let Err(e) = self.save() {
887 log::warn!(
888 "Failed to persist unified config.json after migration: {}",
889 e
890 );
891 }
892 }
893 }
894}
895
896fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
897 let Some(parent) = path.parent() else {
898 return std::fs::write(path, content);
899 };
900
901 std::fs::create_dir_all(parent)?;
902
903 let file_name = path
906 .file_name()
907 .and_then(|s| s.to_str())
908 .unwrap_or("config.json");
909 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
910 let tmp_path = parent.join(tmp_name);
911
912 {
913 let mut file = std::fs::File::create(&tmp_path)?;
914 file.write_all(content)?;
915 file.sync_all()?;
916 }
917
918 std::fs::rename(&tmp_path, path)?;
919 Ok(())
920}
921
922fn backup_legacy_file(path: &std::path::Path) -> std::io::Result<()> {
923 let Some(parent) = path.parent() else {
924 return Ok(());
925 };
926 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
927 return Ok(());
928 };
929 let backup = parent.join(format!("{name}.migrated.bak"));
930 if backup.exists() {
931 return Ok(());
932 }
933 std::fs::rename(path, backup)?;
934 Ok(())
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize)]
942struct OldConfig {
943 #[serde(default)]
944 http_proxy: String,
945 #[serde(default)]
946 https_proxy: String,
947 #[serde(default)]
948 http_proxy_auth: Option<ProxyAuth>,
949 #[serde(default)]
950 https_proxy_auth: Option<ProxyAuth>,
951 api_key: Option<String>,
952 api_base: Option<String>,
953 model: Option<String>,
954 #[serde(default)]
955 headless_auth: bool,
956 #[serde(default = "default_provider")]
958 provider: String,
959 #[serde(default)]
960 server: ServerConfig,
961 #[serde(default)]
962 providers: ProviderConfigs,
963 #[serde(default)]
964 data_dir: Option<PathBuf>,
965
966 #[serde(default)]
967 keyword_masking: KeywordMaskingConfig,
968 #[serde(default)]
969 anthropic_model_mapping: AnthropicModelMapping,
970 #[serde(default)]
971 gemini_model_mapping: GeminiModelMapping,
972 #[serde(default)]
973 mcp: crate::agent::mcp::McpConfig,
974
975 #[serde(default, flatten)]
977 extra: BTreeMap<String, Value>,
978}
979
980fn migrate_config(old: OldConfig) -> Config {
985 if old.api_key.is_some() {
987 log::warn!(
988 "api_key is no longer used. CopilotClient automatically manages authentication."
989 );
990 }
991 if old.api_base.is_some() {
992 log::warn!(
993 "api_base is no longer used. CopilotClient automatically manages API endpoints."
994 );
995 }
996
997 let proxy_auth = old.https_proxy_auth.or(old.http_proxy_auth);
998 let proxy_auth_encrypted = proxy_auth
999 .as_ref()
1000 .and_then(|auth| serde_json::to_string(auth).ok())
1001 .and_then(|auth_str| crate::core::encryption::encrypt(&auth_str).ok());
1002
1003 Config {
1004 http_proxy: old.http_proxy,
1005 https_proxy: old.https_proxy,
1006 proxy_auth,
1008 proxy_auth_encrypted,
1009 model: old.model,
1010 headless_auth: old.headless_auth,
1011 provider: old.provider,
1012 providers: old.providers,
1013 server: old.server,
1014 data_dir: old.data_dir.unwrap_or_else(default_data_dir),
1015 keyword_masking: old.keyword_masking,
1016 anthropic_model_mapping: old.anthropic_model_mapping,
1017 gemini_model_mapping: old.gemini_model_mapping,
1018 mcp: old.mcp,
1019 extra: old.extra,
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 use std::ffi::OsString;
1027 use std::path::PathBuf;
1028 use std::sync::{Mutex, OnceLock};
1029 use std::time::{SystemTime, UNIX_EPOCH};
1030
1031 struct EnvVarGuard {
1032 key: &'static str,
1033 previous: Option<OsString>,
1034 }
1035
1036 impl EnvVarGuard {
1037 fn set(key: &'static str, value: &str) -> Self {
1038 let previous = std::env::var_os(key);
1039 std::env::set_var(key, value);
1040 Self { key, previous }
1041 }
1042
1043 fn unset(key: &'static str) -> Self {
1044 let previous = std::env::var_os(key);
1045 std::env::remove_var(key);
1046 Self { key, previous }
1047 }
1048 }
1049
1050 impl Drop for EnvVarGuard {
1051 fn drop(&mut self) {
1052 match &self.previous {
1053 Some(value) => std::env::set_var(self.key, value),
1054 None => std::env::remove_var(self.key),
1055 }
1056 }
1057 }
1058
1059 struct TempHome {
1060 path: PathBuf,
1061 }
1062
1063 impl TempHome {
1064 fn new() -> Self {
1065 let nanos = SystemTime::now()
1066 .duration_since(UNIX_EPOCH)
1067 .expect("clock should be after unix epoch")
1068 .as_nanos();
1069 let path = std::env::temp_dir().join(format!(
1070 "chat-core-config-test-{}-{}",
1071 std::process::id(),
1072 nanos
1073 ));
1074 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1075 Self { path }
1076 }
1077
1078 fn set_config_json(&self, content: &str) {
1079 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1082 std::fs::write(self.path.join("config.json"), content)
1083 .expect("failed to write config.json");
1084 }
1085 }
1086
1087 impl Drop for TempHome {
1088 fn drop(&mut self) {
1089 let _ = std::fs::remove_dir_all(&self.path);
1090 }
1091 }
1092
1093 fn env_lock() -> &'static Mutex<()> {
1094 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1095 LOCK.get_or_init(|| Mutex::new(()))
1096 }
1097
1098 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1100 env_lock().lock().unwrap_or_else(|poisoned| {
1101 poisoned.into_inner()
1103 })
1104 }
1105
1106 #[test]
1107 fn parse_bool_env_true_values() {
1108 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1109 assert!(parse_bool_env(value), "value {value:?} should be true");
1110 }
1111 }
1112
1113 #[test]
1114 fn parse_bool_env_false_values() {
1115 for value in ["0", "false", "no", "off", "", " "] {
1116 assert!(!parse_bool_env(value), "value {value:?} should be false");
1117 }
1118 }
1119
1120 #[test]
1121 fn config_new_ignores_http_proxy_env_vars() {
1122 let _lock = env_lock_acquire();
1123 let temp_home = TempHome::new();
1124 temp_home.set_config_json(
1125 r#"{
1126 "http_proxy": "",
1127 "https_proxy": ""
1128}"#,
1129 );
1130
1131 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1132 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1133
1134 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1135
1136 assert!(
1137 config.http_proxy.is_empty(),
1138 "config should ignore HTTP_PROXY env var"
1139 );
1140 assert!(
1141 config.https_proxy.is_empty(),
1142 "config should ignore HTTPS_PROXY env var"
1143 );
1144 }
1145
1146 #[test]
1147 fn config_new_loads_config_when_proxy_fields_omitted() {
1148 let _lock = env_lock_acquire();
1149 let temp_home = TempHome::new();
1150 temp_home.set_config_json(
1151 r#"{
1152 "model": "gpt-4"
1153}"#,
1154 );
1155
1156 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1157 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1158
1159 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1160
1161 assert_eq!(
1162 config.model.as_deref(),
1163 Some("gpt-4"),
1164 "config should load model from config file even when proxy fields are omitted"
1165 );
1166 assert!(config.http_proxy.is_empty());
1167 assert!(config.https_proxy.is_empty());
1168 }
1169
1170 #[test]
1171 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
1172 let _lock = env_lock_acquire();
1173 let temp_home = TempHome::new();
1174 temp_home.set_config_json(
1175 r#"{
1176 "model": "gpt-4"
1177}"#,
1178 );
1179
1180 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1181 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1182
1183 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1184
1185 assert_eq!(config.model.as_deref(), Some("gpt-4"));
1186 assert!(
1187 config.http_proxy.is_empty(),
1188 "config should keep http_proxy empty when field is omitted"
1189 );
1190 assert!(
1191 config.https_proxy.is_empty(),
1192 "config should keep https_proxy empty when field is omitted"
1193 );
1194 }
1195
1196 #[test]
1197 fn config_migrates_old_format_to_new() {
1198 let _lock = env_lock_acquire();
1199 let temp_home = TempHome::new();
1200
1201 temp_home.set_config_json(
1203 r#"{
1204 "http_proxy": "http://proxy.example.com:8080",
1205 "https_proxy": "http://proxy.example.com:8443",
1206 "http_proxy_auth": {
1207 "username": "http_user",
1208 "password": "http_pass"
1209 },
1210 "https_proxy_auth": {
1211 "username": "https_user",
1212 "password": "https_pass"
1213 },
1214 "api_key": "old_key",
1215 "api_base": "https://old.api.com",
1216 "model": "gpt-4",
1217 "headless_auth": true
1218}"#,
1219 );
1220
1221 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1222
1223 assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
1225 assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
1226
1227 assert!(config.proxy_auth.is_some());
1229 let auth = config.proxy_auth.unwrap();
1230 assert_eq!(auth.username, "https_user");
1231 assert_eq!(auth.password, "https_pass");
1232
1233 assert_eq!(config.model.as_deref(), Some("gpt-4"));
1235 assert!(config.headless_auth);
1236
1237 }
1239
1240 #[test]
1241 fn config_migrates_only_http_proxy_auth() {
1242 let _lock = env_lock_acquire();
1243 let temp_home = TempHome::new();
1244
1245 temp_home.set_config_json(
1247 r#"{
1248 "http_proxy": "http://proxy.example.com:8080",
1249 "http_proxy_auth": {
1250 "username": "http_user",
1251 "password": "http_pass"
1252 }
1253}"#,
1254 );
1255
1256 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1257
1258 assert!(
1260 config.proxy_auth.is_some(),
1261 "proxy_auth should be migrated from http_proxy_auth"
1262 );
1263 let auth = config.proxy_auth.unwrap();
1264 assert_eq!(auth.username, "http_user");
1265 assert_eq!(auth.password, "http_pass");
1266 }
1267
1268 #[test]
1269 fn test_server_config_defaults() {
1270 let _lock = env_lock_acquire();
1271 let temp_home = TempHome::new();
1272
1273 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1274 assert_eq!(config.server.port, 8080);
1275 assert_eq!(config.server.bind, "127.0.0.1");
1276 assert_eq!(config.server.workers, 10);
1277 assert!(config.server.static_dir.is_none());
1278 }
1279
1280 #[test]
1281 fn test_server_addr() {
1282 let mut config = Config::default();
1283 config.server.port = 9000;
1284 config.server.bind = "0.0.0.0".to_string();
1285 assert_eq!(config.server_addr(), "0.0.0.0:9000");
1286 }
1287
1288 #[test]
1289 fn test_env_var_overrides() {
1290 let _lock = env_lock_acquire();
1291 let temp_home = TempHome::new();
1292
1293 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1294 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1295 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1296
1297 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1298 assert_eq!(config.server.port, 9999);
1299 assert_eq!(config.server.bind, "192.168.1.1");
1300 assert_eq!(config.provider, "openai");
1301 }
1302
1303 #[test]
1304 fn test_config_save_and_load() {
1305 let _lock = env_lock_acquire();
1306 let temp_home = TempHome::new();
1307
1308 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1309 config.server.port = 9000;
1310 config.server.bind = "0.0.0.0".to_string();
1311 config.provider = "anthropic".to_string();
1312
1313 config.save().expect("Failed to save config");
1315
1316 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1318
1319 assert_eq!(loaded.server.port, 9000);
1321 assert_eq!(loaded.server.bind, "0.0.0.0");
1322 assert_eq!(loaded.provider, "anthropic");
1323 }
1324
1325 #[test]
1326 fn config_decrypts_proxy_auth_from_encrypted_field() {
1327 let _lock = env_lock_acquire();
1328 let temp_home = TempHome::new();
1329
1330 let key_guard = crate::core::encryption::set_test_encryption_key([
1332 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1333 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1334 0x1c, 0x1d, 0x1e, 0x1f,
1335 ]);
1336
1337 let auth = ProxyAuth {
1338 username: "user".to_string(),
1339 password: "pass".to_string(),
1340 };
1341 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1342 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1343
1344 temp_home.set_config_json(&format!(
1345 r#"{{
1346 "http_proxy": "http://proxy.example.com:8080",
1347 "proxy_auth_encrypted": "{encrypted}"
1348}}"#
1349 ));
1350 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1351 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1352 assert_eq!(loaded_auth.username, "user");
1353 assert_eq!(loaded_auth.password, "pass");
1354 drop(key_guard);
1355 }
1356
1357 #[test]
1358 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1359 let _lock = env_lock_acquire();
1360 let temp_home = TempHome::new();
1361
1362 let key_guard = crate::core::encryption::set_test_encryption_key([
1364 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1365 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1366 0x1c, 0x1d, 0x1e, 0x1f,
1367 ]);
1368
1369 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1370 config.proxy_auth = Some(ProxyAuth {
1371 username: "user".to_string(),
1372 password: "pass".to_string(),
1373 });
1374 config.save().expect("save should encrypt proxy auth");
1375
1376 let content =
1377 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1378 assert!(
1379 content.contains("proxy_auth_encrypted"),
1380 "config.json should store encrypted proxy auth"
1381 );
1382 assert!(
1383 !content.contains("\"proxy_auth\""),
1384 "config.json should not store plaintext proxy_auth"
1385 );
1386
1387 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1388 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1389 assert_eq!(loaded_auth.username, "user");
1390 assert_eq!(loaded_auth.password, "pass");
1391 drop(key_guard);
1392 }
1393
1394 #[test]
1395 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
1396 let _lock = env_lock_acquire();
1397 let temp_home = TempHome::new();
1398
1399 let key_guard = crate::core::encryption::set_test_encryption_key([
1401 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1402 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1403 0x1c, 0x1d, 0x1e, 0x1f,
1404 ]);
1405
1406 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1407 config.provider = "openai".to_string();
1408 config.providers.openai = Some(OpenAIConfig {
1409 api_key: "sk-test-provider-key".to_string(),
1410 api_key_encrypted: None,
1411 base_url: None,
1412 model: None,
1413 extra: Default::default(),
1414 });
1415
1416 config
1417 .save()
1418 .expect("save should encrypt provider api keys");
1419
1420 let content =
1421 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1422 assert!(
1423 content.contains("\"api_key_encrypted\""),
1424 "config.json should store encrypted provider keys"
1425 );
1426 assert!(
1427 !content.contains("\"api_key\""),
1428 "config.json should not store plaintext provider keys"
1429 );
1430
1431 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1432 let openai = loaded
1433 .providers
1434 .openai
1435 .expect("openai config should be present");
1436 assert_eq!(openai.api_key, "sk-test-provider-key");
1437
1438 drop(key_guard);
1439 }
1440
1441 #[test]
1442 fn config_save_encrypts_mcp_secrets_and_does_not_persist_plaintext() {
1443 let _lock = env_lock_acquire();
1444 let temp_home = TempHome::new();
1445
1446 let key_guard = crate::core::encryption::set_test_encryption_key([
1448 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1449 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1450 0x1c, 0x1d, 0x1e, 0x1f,
1451 ]);
1452
1453 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1454
1455 let mut env = std::collections::HashMap::new();
1456 env.insert("TOKEN".to_string(), "supersecret".to_string());
1457
1458 config.mcp.servers = vec![
1459 crate::agent::mcp::McpServerConfig {
1460 id: "stdio-secret".to_string(),
1461 name: None,
1462 enabled: true,
1463 transport: crate::agent::mcp::TransportConfig::Stdio(
1464 crate::agent::mcp::StdioConfig {
1465 command: "echo".to_string(),
1466 args: vec![],
1467 cwd: None,
1468 env,
1469 env_encrypted: std::collections::HashMap::new(),
1470 startup_timeout_ms: 5000,
1471 },
1472 ),
1473 request_timeout_ms: 5000,
1474 healthcheck_interval_ms: 1000,
1475 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1476 allowed_tools: vec![],
1477 denied_tools: vec![],
1478 },
1479 crate::agent::mcp::McpServerConfig {
1480 id: "sse-secret".to_string(),
1481 name: None,
1482 enabled: true,
1483 transport: crate::agent::mcp::TransportConfig::Sse(crate::agent::mcp::SseConfig {
1484 url: "http://localhost:8080/sse".to_string(),
1485 headers: vec![crate::agent::mcp::HeaderConfig {
1486 name: "Authorization".to_string(),
1487 value: "Bearer token123".to_string(),
1488 value_encrypted: None,
1489 }],
1490 connect_timeout_ms: 5000,
1491 }),
1492 request_timeout_ms: 5000,
1493 healthcheck_interval_ms: 1000,
1494 reconnect: crate::agent::mcp::ReconnectConfig::default(),
1495 allowed_tools: vec![],
1496 denied_tools: vec![],
1497 },
1498 ];
1499
1500 config.save().expect("save should encrypt MCP secrets");
1501
1502 let content =
1503 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1504 assert!(
1505 content.contains("\"env_encrypted\""),
1506 "config.json should store encrypted MCP stdio env"
1507 );
1508 assert!(
1509 content.contains("\"value_encrypted\""),
1510 "config.json should store encrypted MCP SSE headers"
1511 );
1512 assert!(
1513 !content.contains("supersecret"),
1514 "config.json must not contain plaintext env values"
1515 );
1516 assert!(
1517 !content.contains("Bearer token123"),
1518 "config.json must not contain plaintext header values"
1519 );
1520
1521 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1522 let stdio = loaded
1523 .mcp
1524 .servers
1525 .iter()
1526 .find(|s| s.id == "stdio-secret")
1527 .expect("stdio server should exist");
1528 match &stdio.transport {
1529 crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1530 assert_eq!(
1531 stdio.env.get("TOKEN").map(|s| s.as_str()),
1532 Some("supersecret")
1533 );
1534 }
1535 _ => panic!("Expected stdio transport"),
1536 }
1537
1538 let sse = loaded
1539 .mcp
1540 .servers
1541 .iter()
1542 .find(|s| s.id == "sse-secret")
1543 .expect("sse server should exist");
1544 match &sse.transport {
1545 crate::agent::mcp::TransportConfig::Sse(sse) => {
1546 assert_eq!(sse.headers[0].value, "Bearer token123");
1547 }
1548 _ => panic!("Expected SSE transport"),
1549 }
1550
1551 drop(key_guard);
1552 }
1553}