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