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 pub api_key: String,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub base_url: Option<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub model: Option<String>,
183
184 #[serde(default, flatten)]
186 pub extra: BTreeMap<String, Value>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AnthropicConfig {
202 pub api_key: String,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub base_url: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub model: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub max_tokens: Option<u32>,
213
214 #[serde(default, flatten)]
216 pub extra: BTreeMap<String, Value>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct GeminiConfig {
231 pub api_key: String,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub base_url: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub model: Option<String>,
239
240 #[serde(default, flatten)]
242 pub extra: BTreeMap<String, Value>,
243}
244
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257pub struct CopilotConfig {
258 #[serde(default)]
260 pub enabled: bool,
261 #[serde(default)]
263 pub headless_auth: bool,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub model: Option<String>,
267
268 #[serde(default, flatten)]
270 pub extra: BTreeMap<String, Value>,
271}
272
273fn default_provider() -> String {
275 "anthropic".to_string()
276}
277
278fn default_port() -> u16 {
280 8080
281}
282
283fn default_bind() -> String {
285 "127.0.0.1".to_string()
286}
287
288fn default_workers() -> usize {
290 10
291}
292
293fn default_data_dir() -> PathBuf {
295 super::paths::bamboo_dir()
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ServerConfig {
301 #[serde(default = "default_port")]
303 pub port: u16,
304
305 #[serde(default = "default_bind")]
307 pub bind: String,
308
309 pub static_dir: Option<PathBuf>,
311
312 #[serde(default = "default_workers")]
314 pub workers: usize,
315
316 #[serde(default, flatten)]
318 pub extra: BTreeMap<String, Value>,
319}
320
321impl Default for ServerConfig {
322 fn default() -> Self {
323 Self {
324 port: default_port(),
325 bind: default_bind(),
326 static_dir: None,
327 workers: default_workers(),
328 extra: BTreeMap::new(),
329 }
330 }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ProxyAuth {
336 pub username: String,
338 pub password: String,
340}
341
342const CONFIG_FILE_PATH: &str = "config.toml";
344
345fn parse_bool_env(value: &str) -> bool {
349 matches!(
350 value.trim().to_ascii_lowercase().as_str(),
351 "1" | "true" | "yes" | "y" | "on"
352 )
353}
354
355impl Default for Config {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361impl Config {
362 pub fn new() -> Self {
380 Self::from_data_dir(None)
381 }
382
383 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
389 let data_dir = data_dir
391 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
392 .unwrap_or_else(default_data_dir);
393
394 let config_path = data_dir.join("config.json");
395
396 let mut config = if config_path.exists() {
397 if let Ok(content) = std::fs::read_to_string(&config_path) {
398 if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
400 let has_old_fields = old_config.http_proxy_auth.is_some()
402 || old_config.https_proxy_auth.is_some()
403 || old_config.api_key.is_some()
404 || old_config.api_base.is_some();
405
406 if has_old_fields {
407 log::info!("Migrating old config format to new format");
408 let migrated = migrate_config(old_config);
409 if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
411 let _ = std::fs::write(&config_path, new_content);
412 }
413 migrated
414 } else {
415 match serde_json::from_str::<Config>(&content) {
419 Ok(mut config) => {
420 config.hydrate_proxy_auth_from_encrypted();
421 config
422 }
423 Err(_) => {
424 migrate_config(old_config)
426 }
427 }
428 }
429 } else {
430 serde_json::from_str::<Config>(&content)
432 .map(|mut config| {
433 config.hydrate_proxy_auth_from_encrypted();
434 config
435 })
436 .unwrap_or_else(|_| Self::create_default())
437 }
438 } else {
439 Self::create_default()
440 }
441 } else {
442 if std::path::Path::new(CONFIG_FILE_PATH).exists() {
444 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_PATH) {
445 if let Ok(old_config) = toml::from_str::<OldConfig>(&content) {
446 migrate_config(old_config)
447 } else {
448 Self::create_default()
449 }
450 } else {
451 Self::create_default()
452 }
453 } else {
454 Self::create_default()
455 }
456 };
457
458 config.data_dir = data_dir;
460 config.hydrate_proxy_auth_from_encrypted();
462
463 config.migrate_legacy_sidecar_configs();
466
467 if let Ok(port) = std::env::var("BAMBOO_PORT") {
469 if let Ok(port) = port.parse() {
470 config.server.port = port;
471 }
472 }
473
474 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
475 config.server.bind = bind;
476 }
477
478 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
480 config.provider = provider;
481 }
482
483 if let Ok(model) = std::env::var("MODEL") {
484 config.model = Some(model);
485 }
486
487 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
488 config.headless_auth = parse_bool_env(&headless);
489 }
490
491 config
492 }
493
494 pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
500 if self.proxy_auth.is_some() {
501 return;
502 }
503
504 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
505 return;
506 };
507
508 match crate::core::encryption::decrypt(encrypted) {
509 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
510 Ok(auth) => self.proxy_auth = Some(auth),
511 Err(e) => log::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
512 },
513 Err(e) => log::warn!("Failed to decrypt proxy auth: {}", e),
514 }
515 }
516
517 pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
522 let Some(auth) = self.proxy_auth.as_ref() else {
526 self.proxy_auth_encrypted = None;
527 return Ok(());
528 };
529
530 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
531 let encrypted =
532 crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
533 self.proxy_auth_encrypted = Some(encrypted);
534 Ok(())
535 }
536
537 fn create_default() -> Self {
539 Config {
540 http_proxy: String::new(),
541 https_proxy: String::new(),
542 proxy_auth: None,
543 proxy_auth_encrypted: None,
544 model: None,
545 headless_auth: false,
546 provider: default_provider(),
547 providers: ProviderConfigs::default(),
548 server: ServerConfig::default(),
549 data_dir: default_data_dir(),
550 keyword_masking: KeywordMaskingConfig::default(),
551 anthropic_model_mapping: AnthropicModelMapping::default(),
552 gemini_model_mapping: GeminiModelMapping::default(),
553 mcp: crate::agent::mcp::McpConfig::default(),
554 extra: BTreeMap::new(),
555 }
556 }
557
558 pub fn server_addr(&self) -> String {
560 format!("{}:{}", self.server.bind, self.server.port)
561 }
562
563 pub fn save(&self) -> Result<()> {
565 let path = self.data_dir.join("config.json");
566
567 if let Some(parent) = path.parent() {
568 std::fs::create_dir_all(parent)
569 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
570 }
571
572 let mut to_save = self.clone();
573 to_save.refresh_proxy_auth_encrypted()?;
574 let content =
575 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
576 write_atomic(&path, content.as_bytes())
577 .with_context(|| format!("Failed to write config file: {:?}", path))?;
578
579 Ok(())
580 }
581
582 fn migrate_legacy_sidecar_configs(&mut self) {
583 let data_dir = self.data_dir.clone();
584 let mut changed = false;
585
586 if self.keyword_masking.entries.is_empty() {
588 let path = data_dir.join("keyword_masking.json");
589 if path.exists() {
590 match std::fs::read_to_string(&path) {
591 Ok(content) => match serde_json::from_str::<KeywordMaskingConfig>(&content) {
592 Ok(km) => {
593 self.keyword_masking = km;
594 changed = true;
595 let _ = backup_legacy_file(&path);
596 }
597 Err(e) => log::warn!(
598 "Failed to migrate keyword_masking.json into config.json: {}",
599 e
600 ),
601 },
602 Err(e) => {
603 log::warn!("Failed to read keyword_masking.json for migration: {}", e)
604 }
605 }
606 }
607 }
608
609 if self.anthropic_model_mapping.mappings.is_empty() {
611 let path = data_dir.join("anthropic-model-mapping.json");
612 if path.exists() {
613 match std::fs::read_to_string(&path) {
614 Ok(content) => match serde_json::from_str::<AnthropicModelMapping>(&content) {
615 Ok(mapping) => {
616 self.anthropic_model_mapping = mapping;
617 changed = true;
618 let _ = backup_legacy_file(&path);
619 }
620 Err(e) => log::warn!(
621 "Failed to migrate anthropic-model-mapping.json into config.json: {}",
622 e
623 ),
624 },
625 Err(e) => log::warn!(
626 "Failed to read anthropic-model-mapping.json for migration: {}",
627 e
628 ),
629 }
630 }
631 }
632
633 if self.gemini_model_mapping.mappings.is_empty() {
635 let path = data_dir.join("gemini-model-mapping.json");
636 if path.exists() {
637 match std::fs::read_to_string(&path) {
638 Ok(content) => match serde_json::from_str::<GeminiModelMapping>(&content) {
639 Ok(mapping) => {
640 self.gemini_model_mapping = mapping;
641 changed = true;
642 let _ = backup_legacy_file(&path);
643 }
644 Err(e) => log::warn!(
645 "Failed to migrate gemini-model-mapping.json into config.json: {}",
646 e
647 ),
648 },
649 Err(e) => log::warn!(
650 "Failed to read gemini-model-mapping.json for migration: {}",
651 e
652 ),
653 }
654 }
655 }
656
657 if self.mcp.servers.is_empty() {
659 let path = data_dir.join("mcp.json");
660 if path.exists() {
661 match std::fs::read_to_string(&path) {
662 Ok(content) => {
663 match serde_json::from_str::<crate::agent::mcp::McpConfig>(&content) {
664 Ok(mcp) => {
665 self.mcp = mcp;
666 changed = true;
667 let _ = backup_legacy_file(&path);
668 }
669 Err(e) => {
670 log::warn!("Failed to migrate mcp.json into config.json: {}", e)
671 }
672 }
673 }
674 Err(e) => log::warn!("Failed to read mcp.json for migration: {}", e),
675 }
676 }
677 }
678
679 if !self.extra.contains_key("permissions") {
681 let path = data_dir.join("permissions.json");
682 if path.exists() {
683 match std::fs::read_to_string(&path) {
684 Ok(content) => match serde_json::from_str::<Value>(&content) {
685 Ok(value) => {
686 self.extra.insert("permissions".to_string(), value);
687 changed = true;
688 let _ = backup_legacy_file(&path);
689 }
690 Err(e) => {
691 log::warn!("Failed to migrate permissions.json into config.json: {}", e)
692 }
693 },
694 Err(e) => log::warn!("Failed to read permissions.json for migration: {}", e),
695 }
696 }
697 }
698
699 if changed {
700 if let Err(e) = self.save() {
701 log::warn!(
702 "Failed to persist unified config.json after migration: {}",
703 e
704 );
705 }
706 }
707 }
708}
709
710fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
711 let Some(parent) = path.parent() else {
712 return std::fs::write(path, content);
713 };
714
715 std::fs::create_dir_all(parent)?;
716
717 let file_name = path
720 .file_name()
721 .and_then(|s| s.to_str())
722 .unwrap_or("config.json");
723 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
724 let tmp_path = parent.join(tmp_name);
725
726 {
727 let mut file = std::fs::File::create(&tmp_path)?;
728 file.write_all(content)?;
729 file.sync_all()?;
730 }
731
732 std::fs::rename(&tmp_path, path)?;
733 Ok(())
734}
735
736fn backup_legacy_file(path: &std::path::Path) -> std::io::Result<()> {
737 let Some(parent) = path.parent() else {
738 return Ok(());
739 };
740 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
741 return Ok(());
742 };
743 let backup = parent.join(format!("{name}.migrated.bak"));
744 if backup.exists() {
745 return Ok(());
746 }
747 std::fs::rename(path, backup)?;
748 Ok(())
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
756struct OldConfig {
757 #[serde(default)]
758 http_proxy: String,
759 #[serde(default)]
760 https_proxy: String,
761 #[serde(default)]
762 http_proxy_auth: Option<ProxyAuth>,
763 #[serde(default)]
764 https_proxy_auth: Option<ProxyAuth>,
765 api_key: Option<String>,
766 api_base: Option<String>,
767 model: Option<String>,
768 #[serde(default)]
769 headless_auth: bool,
770 #[serde(default = "default_provider")]
772 provider: String,
773 #[serde(default)]
774 server: ServerConfig,
775 #[serde(default)]
776 providers: ProviderConfigs,
777 #[serde(default)]
778 data_dir: Option<PathBuf>,
779
780 #[serde(default)]
781 keyword_masking: KeywordMaskingConfig,
782 #[serde(default)]
783 anthropic_model_mapping: AnthropicModelMapping,
784 #[serde(default)]
785 gemini_model_mapping: GeminiModelMapping,
786 #[serde(default)]
787 mcp: crate::agent::mcp::McpConfig,
788
789 #[serde(default, flatten)]
791 extra: BTreeMap<String, Value>,
792}
793
794fn migrate_config(old: OldConfig) -> Config {
799 if old.api_key.is_some() {
801 log::warn!(
802 "api_key is no longer used. CopilotClient automatically manages authentication."
803 );
804 }
805 if old.api_base.is_some() {
806 log::warn!(
807 "api_base is no longer used. CopilotClient automatically manages API endpoints."
808 );
809 }
810
811 let proxy_auth = old.https_proxy_auth.or(old.http_proxy_auth);
812 let proxy_auth_encrypted = proxy_auth
813 .as_ref()
814 .and_then(|auth| serde_json::to_string(auth).ok())
815 .and_then(|auth_str| crate::core::encryption::encrypt(&auth_str).ok());
816
817 Config {
818 http_proxy: old.http_proxy,
819 https_proxy: old.https_proxy,
820 proxy_auth,
822 proxy_auth_encrypted,
823 model: old.model,
824 headless_auth: old.headless_auth,
825 provider: old.provider,
826 providers: old.providers,
827 server: old.server,
828 data_dir: old.data_dir.unwrap_or_else(default_data_dir),
829 keyword_masking: old.keyword_masking,
830 anthropic_model_mapping: old.anthropic_model_mapping,
831 gemini_model_mapping: old.gemini_model_mapping,
832 mcp: old.mcp,
833 extra: old.extra,
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use std::ffi::OsString;
841 use std::path::PathBuf;
842 use std::sync::{Mutex, OnceLock};
843 use std::time::{SystemTime, UNIX_EPOCH};
844
845 struct EnvVarGuard {
846 key: &'static str,
847 previous: Option<OsString>,
848 }
849
850 impl EnvVarGuard {
851 fn set(key: &'static str, value: &str) -> Self {
852 let previous = std::env::var_os(key);
853 std::env::set_var(key, value);
854 Self { key, previous }
855 }
856
857 fn unset(key: &'static str) -> Self {
858 let previous = std::env::var_os(key);
859 std::env::remove_var(key);
860 Self { key, previous }
861 }
862 }
863
864 impl Drop for EnvVarGuard {
865 fn drop(&mut self) {
866 match &self.previous {
867 Some(value) => std::env::set_var(self.key, value),
868 None => std::env::remove_var(self.key),
869 }
870 }
871 }
872
873 struct TempHome {
874 path: PathBuf,
875 }
876
877 impl TempHome {
878 fn new() -> Self {
879 let nanos = SystemTime::now()
880 .duration_since(UNIX_EPOCH)
881 .expect("clock should be after unix epoch")
882 .as_nanos();
883 let path = std::env::temp_dir().join(format!(
884 "chat-core-config-test-{}-{}",
885 std::process::id(),
886 nanos
887 ));
888 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
889 Self { path }
890 }
891
892 fn set_config_json(&self, content: &str) {
893 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
896 std::fs::write(self.path.join("config.json"), content)
897 .expect("failed to write config.json");
898 }
899 }
900
901 impl Drop for TempHome {
902 fn drop(&mut self) {
903 let _ = std::fs::remove_dir_all(&self.path);
904 }
905 }
906
907 fn env_lock() -> &'static Mutex<()> {
908 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
909 LOCK.get_or_init(|| Mutex::new(()))
910 }
911
912 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
914 env_lock().lock().unwrap_or_else(|poisoned| {
915 poisoned.into_inner()
917 })
918 }
919
920 #[test]
921 fn parse_bool_env_true_values() {
922 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
923 assert!(parse_bool_env(value), "value {value:?} should be true");
924 }
925 }
926
927 #[test]
928 fn parse_bool_env_false_values() {
929 for value in ["0", "false", "no", "off", "", " "] {
930 assert!(!parse_bool_env(value), "value {value:?} should be false");
931 }
932 }
933
934 #[test]
935 fn config_new_ignores_http_proxy_env_vars() {
936 let _lock = env_lock_acquire();
937 let temp_home = TempHome::new();
938 temp_home.set_config_json(
939 r#"{
940 "http_proxy": "",
941 "https_proxy": ""
942}"#,
943 );
944
945 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
946 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
947
948 let config = Config::from_data_dir(Some(temp_home.path.clone()));
949
950 assert!(
951 config.http_proxy.is_empty(),
952 "config should ignore HTTP_PROXY env var"
953 );
954 assert!(
955 config.https_proxy.is_empty(),
956 "config should ignore HTTPS_PROXY env var"
957 );
958 }
959
960 #[test]
961 fn config_new_loads_config_when_proxy_fields_omitted() {
962 let _lock = env_lock_acquire();
963 let temp_home = TempHome::new();
964 temp_home.set_config_json(
965 r#"{
966 "model": "gpt-4"
967}"#,
968 );
969
970 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
971 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
972
973 let config = Config::from_data_dir(Some(temp_home.path.clone()));
974
975 assert_eq!(
976 config.model.as_deref(),
977 Some("gpt-4"),
978 "config should load model from config file even when proxy fields are omitted"
979 );
980 assert!(config.http_proxy.is_empty());
981 assert!(config.https_proxy.is_empty());
982 }
983
984 #[test]
985 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
986 let _lock = env_lock_acquire();
987 let temp_home = TempHome::new();
988 temp_home.set_config_json(
989 r#"{
990 "model": "gpt-4"
991}"#,
992 );
993
994 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
995 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
996
997 let config = Config::from_data_dir(Some(temp_home.path.clone()));
998
999 assert_eq!(config.model.as_deref(), Some("gpt-4"));
1000 assert!(
1001 config.http_proxy.is_empty(),
1002 "config should keep http_proxy empty when field is omitted"
1003 );
1004 assert!(
1005 config.https_proxy.is_empty(),
1006 "config should keep https_proxy empty when field is omitted"
1007 );
1008 }
1009
1010 #[test]
1011 fn config_migrates_old_format_to_new() {
1012 let _lock = env_lock_acquire();
1013 let temp_home = TempHome::new();
1014
1015 temp_home.set_config_json(
1017 r#"{
1018 "http_proxy": "http://proxy.example.com:8080",
1019 "https_proxy": "http://proxy.example.com:8443",
1020 "http_proxy_auth": {
1021 "username": "http_user",
1022 "password": "http_pass"
1023 },
1024 "https_proxy_auth": {
1025 "username": "https_user",
1026 "password": "https_pass"
1027 },
1028 "api_key": "old_key",
1029 "api_base": "https://old.api.com",
1030 "model": "gpt-4",
1031 "headless_auth": true
1032}"#,
1033 );
1034
1035 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1036
1037 assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
1039 assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
1040
1041 assert!(config.proxy_auth.is_some());
1043 let auth = config.proxy_auth.unwrap();
1044 assert_eq!(auth.username, "https_user");
1045 assert_eq!(auth.password, "https_pass");
1046
1047 assert_eq!(config.model.as_deref(), Some("gpt-4"));
1049 assert!(config.headless_auth);
1050
1051 }
1053
1054 #[test]
1055 fn config_migrates_only_http_proxy_auth() {
1056 let _lock = env_lock_acquire();
1057 let temp_home = TempHome::new();
1058
1059 temp_home.set_config_json(
1061 r#"{
1062 "http_proxy": "http://proxy.example.com:8080",
1063 "http_proxy_auth": {
1064 "username": "http_user",
1065 "password": "http_pass"
1066 }
1067}"#,
1068 );
1069
1070 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1071
1072 assert!(
1074 config.proxy_auth.is_some(),
1075 "proxy_auth should be migrated from http_proxy_auth"
1076 );
1077 let auth = config.proxy_auth.unwrap();
1078 assert_eq!(auth.username, "http_user");
1079 assert_eq!(auth.password, "http_pass");
1080 }
1081
1082 #[test]
1083 fn test_server_config_defaults() {
1084 let _lock = env_lock_acquire();
1085 let temp_home = TempHome::new();
1086
1087 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1088 assert_eq!(config.server.port, 8080);
1089 assert_eq!(config.server.bind, "127.0.0.1");
1090 assert_eq!(config.server.workers, 10);
1091 assert!(config.server.static_dir.is_none());
1092 }
1093
1094 #[test]
1095 fn test_server_addr() {
1096 let mut config = Config::default();
1097 config.server.port = 9000;
1098 config.server.bind = "0.0.0.0".to_string();
1099 assert_eq!(config.server_addr(), "0.0.0.0:9000");
1100 }
1101
1102 #[test]
1103 fn test_env_var_overrides() {
1104 let _lock = env_lock_acquire();
1105 let temp_home = TempHome::new();
1106
1107 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1108 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1109 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1110
1111 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1112 assert_eq!(config.server.port, 9999);
1113 assert_eq!(config.server.bind, "192.168.1.1");
1114 assert_eq!(config.provider, "openai");
1115 }
1116
1117 #[test]
1118 fn test_config_save_and_load() {
1119 let _lock = env_lock_acquire();
1120 let temp_home = TempHome::new();
1121
1122 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1123 config.server.port = 9000;
1124 config.server.bind = "0.0.0.0".to_string();
1125 config.provider = "anthropic".to_string();
1126
1127 config.save().expect("Failed to save config");
1129
1130 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1132
1133 assert_eq!(loaded.server.port, 9000);
1135 assert_eq!(loaded.server.bind, "0.0.0.0");
1136 assert_eq!(loaded.provider, "anthropic");
1137 }
1138
1139 #[test]
1140 fn config_decrypts_proxy_auth_from_encrypted_field() {
1141 let _lock = env_lock_acquire();
1142 let temp_home = TempHome::new();
1143
1144 let key_guard = crate::core::encryption::set_test_encryption_key([
1146 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1147 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1148 0x1c, 0x1d, 0x1e, 0x1f,
1149 ]);
1150
1151 let auth = ProxyAuth {
1152 username: "user".to_string(),
1153 password: "pass".to_string(),
1154 };
1155 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1156 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1157
1158 temp_home.set_config_json(&format!(
1159 r#"{{
1160 "http_proxy": "http://proxy.example.com:8080",
1161 "proxy_auth_encrypted": "{encrypted}"
1162}}"#
1163 ));
1164 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1165 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1166 assert_eq!(loaded_auth.username, "user");
1167 assert_eq!(loaded_auth.password, "pass");
1168 drop(key_guard);
1169 }
1170
1171 #[test]
1172 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1173 let _lock = env_lock_acquire();
1174 let temp_home = TempHome::new();
1175
1176 let key_guard = crate::core::encryption::set_test_encryption_key([
1178 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1179 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1180 0x1c, 0x1d, 0x1e, 0x1f,
1181 ]);
1182
1183 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1184 config.proxy_auth = Some(ProxyAuth {
1185 username: "user".to_string(),
1186 password: "pass".to_string(),
1187 });
1188 config.save().expect("save should encrypt proxy auth");
1189
1190 let content =
1191 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1192 assert!(
1193 content.contains("proxy_auth_encrypted"),
1194 "config.json should store encrypted proxy auth"
1195 );
1196 assert!(
1197 !content.contains("\"proxy_auth\""),
1198 "config.json should not store plaintext proxy_auth"
1199 );
1200
1201 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1202 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1203 assert_eq!(loaded_auth.username, "user");
1204 assert_eq!(loaded_auth.password, "pass");
1205 drop(key_guard);
1206 }
1207}