1use anyhow::{Context, Result};
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53use std::collections::{BTreeMap, BTreeSet, HashMap};
54use std::io::Write;
55use std::path::PathBuf;
56use std::sync::{OnceLock, RwLock};
57
58use crate::keyword_masking::KeywordMaskingConfig;
59use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
60use bamboo_domain::tool_names::normalize_tool_ref;
61use bamboo_domain::ReasoningEffort;
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct EnvVarEntry {
69 pub name: String,
71 #[serde(default)]
74 pub value: String,
75 #[serde(default)]
77 pub secret: bool,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub value_encrypted: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub struct DefaultWorkAreaConfig {
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub path: Option<String>,
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
99pub struct AccessControlConfig {
100 #[serde(default)]
102 pub password_enabled: bool,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub password_hash: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub password_salt: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub updated_at: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct MemoryConfig {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub background_model: Option<String>,
121 #[serde(default)]
123 pub auto_dream_enabled: bool,
124 #[serde(
126 default = "default_true_memory_project_prompt_injection",
127 alias = "memory_project_prompt_injection"
128 )]
129 pub project_prompt_injection: bool,
130 #[serde(
132 default = "default_true_memory_relevant_recall",
133 alias = "memory_relevant_recall"
134 )]
135 pub relevant_recall: bool,
136 #[serde(default, alias = "memory_relevant_recall_rerank")]
139 pub relevant_recall_rerank: bool,
140 #[serde(
142 default = "default_true_memory_project_first_dream",
143 alias = "memory_project_first_dream"
144 )]
145 pub project_first_dream: bool,
146 #[serde(default, alias = "memory_dream_refine_mode")]
150 pub dream_refine_mode: bool,
151}
152
153impl Default for MemoryConfig {
154 fn default() -> Self {
155 Self {
156 background_model: None,
157 auto_dream_enabled: false,
158 project_prompt_injection: default_true_memory_project_prompt_injection(),
159 relevant_recall: default_true_memory_relevant_recall(),
160 relevant_recall_rerank: false,
161 project_first_dream: default_true_memory_project_first_dream(),
162 dream_refine_mode: false,
163 }
164 }
165}
166
167fn default_true_memory_project_prompt_injection() -> bool {
168 true
169}
170
171fn default_true_memory_relevant_recall() -> bool {
172 true
173}
174
175fn default_true_memory_project_first_dream() -> bool {
176 true
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct Config {
185 #[serde(default)]
187 pub http_proxy: String,
188 #[serde(default)]
190 pub https_proxy: String,
191 #[serde(skip_serializing)]
195 pub proxy_auth: Option<ProxyAuth>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub proxy_auth_encrypted: Option<String>,
202 #[serde(default)]
204 pub headless_auth: bool,
205
206 #[serde(default = "default_provider")]
208 pub provider: String,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub defaults: Option<DefaultsConfig>,
213
214 #[serde(default)]
216 pub providers: ProviderConfigs,
217
218 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
225 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
226
227 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub default_provider_instance: Option<String>,
232
233 #[serde(default)]
235 pub server: ServerConfig,
236
237 #[serde(default)]
241 pub keyword_masking: KeywordMaskingConfig,
242
243 #[serde(default)]
247 pub anthropic_model_mapping: AnthropicModelMapping,
248
249 #[serde(default)]
253 pub gemini_model_mapping: GeminiModelMapping,
254
255 #[serde(default)]
260 pub hooks: HooksConfig,
261
262 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
266 pub tools: ToolsConfig,
267
268 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
273 pub skills: SkillsConfig,
274
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub env_vars: Vec<EnvVarEntry>,
280
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub default_work_area: Option<DefaultWorkAreaConfig>,
284
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub access_control: Option<AccessControlConfig>,
288
289 #[serde(default)]
291 pub features: FeatureFlags,
292
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub memory: Option<MemoryConfig>,
296
297 #[serde(default, rename = "mcpServers", alias = "mcp")]
303 pub mcp: bamboo_domain::mcp_config::McpConfig,
304
305 #[serde(default, flatten)]
311 pub extra: BTreeMap<String, Value>,
312}
313
314#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318pub struct ProviderConfigs {
319 #[serde(skip_serializing_if = "Option::is_none")]
321 pub openai: Option<OpenAIConfig>,
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub anthropic: Option<AnthropicConfig>,
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub gemini: Option<GeminiConfig>,
328 #[serde(skip_serializing_if = "Option::is_none")]
330 pub copilot: Option<CopilotConfig>,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub bodhi: Option<BodhiConfig>,
334
335 #[serde(default, flatten)]
337 pub extra: BTreeMap<String, Value>,
338}
339
340#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct FeatureFlags {
343 #[serde(default)]
345 pub provider_model_ref: bool,
346 #[serde(default)]
348 pub dynamic_model_routing: bool,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
355pub struct DefaultsConfig {
356 pub chat: bamboo_domain::ProviderModelRef,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub fast: Option<bamboo_domain::ProviderModelRef>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub vision: Option<bamboo_domain::ProviderModelRef>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub planning: Option<bamboo_domain::ProviderModelRef>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub search: Option<bamboo_domain::ProviderModelRef>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub code_review: Option<bamboo_domain::ProviderModelRef>,
377 #[serde(
380 default,
381 skip_serializing_if = "Option::is_none",
382 alias = "sub_session"
383 )]
384 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
385 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
389 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
390}
391
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
394pub struct HooksConfig {
395 #[serde(default)]
397 pub image_fallback: ImageFallbackHookConfig,
398}
399
400#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
407pub struct RequestOverridesConfig {
408 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
410 pub common: RequestScopeOverride,
411 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
413 pub endpoints: BTreeMap<String, RequestScopeOverride>,
414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub rules: Vec<ModelRequestRule>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421pub struct ModelRequestRule {
422 pub model_pattern: String,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub endpoint: Option<String>,
427 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
429 pub scope: RequestScopeOverride,
430}
431
432#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
434pub struct RequestScopeOverride {
435 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
437 pub headers: BTreeMap<String, TemplateExpr>,
438 #[serde(default, skip_serializing_if = "Vec::is_empty")]
440 pub body_patch: Vec<BodyPatch>,
441}
442
443impl RequestScopeOverride {
444 pub fn is_empty(&self) -> bool {
445 self.headers.is_empty() && self.body_patch.is_empty()
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
451pub struct BodyPatch {
452 pub path: String,
454 #[serde(default)]
456 pub op: BodyPatchOp,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub value: Option<PatchValue>,
460}
461
462#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
464#[serde(rename_all = "snake_case")]
465pub enum BodyPatchOp {
466 #[default]
467 Set,
468 Remove,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
473#[serde(untagged)]
474pub enum PatchValue {
475 Template(TemplateExpr),
476 Json(Value),
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[serde(untagged)]
482pub enum TemplateExpr {
483 Literal(String),
485 Structured(TemplateExprSpec),
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
491#[serde(tag = "type", rename_all = "snake_case")]
492pub enum TemplateExprSpec {
493 Literal { value: String },
495 EnvRef {
497 name: String,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 fallback: Option<String>,
500 },
501 Generated { generator: GeneratedValue },
503 Format { template: String },
505}
506
507#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
509#[serde(rename_all = "snake_case")]
510pub enum GeneratedValue {
511 Uuid,
512 UnixMs,
513}
514
515#[derive(Debug, Clone, Default, Serialize, Deserialize)]
517pub struct ToolsConfig {
518 #[serde(default, skip_serializing_if = "Vec::is_empty")]
520 pub disabled: Vec<String>,
521}
522
523impl ToolsConfig {
524 fn is_empty(&self) -> bool {
525 self.disabled.is_empty()
526 }
527}
528
529#[derive(Debug, Clone, Default, Serialize, Deserialize)]
531pub struct SkillsConfig {
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
534 pub disabled: Vec<String>,
535}
536
537impl SkillsConfig {
538 fn is_empty(&self) -> bool {
539 self.disabled.is_empty()
540 }
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct ImageFallbackHookConfig {
549 #[serde(default = "default_true_hooks")]
550 pub enabled: bool,
551
552 #[serde(default = "default_image_fallback_mode")]
554 pub mode: String,
555}
556
557impl Default for ImageFallbackHookConfig {
558 fn default() -> Self {
559 Self {
560 enabled: default_true_hooks(),
561 mode: default_image_fallback_mode(),
562 }
563 }
564}
565
566fn default_image_fallback_mode() -> String {
567 "placeholder".to_string()
568}
569
570fn default_true_hooks() -> bool {
571 false
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct OpenAIConfig {
589 #[serde(default, skip_serializing)]
593 pub api_key: String,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub api_key_encrypted: Option<String>,
597 #[serde(skip_serializing_if = "Option::is_none")]
599 pub base_url: Option<String>,
600 #[serde(skip_serializing_if = "Option::is_none")]
602 pub model: Option<String>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub fast_model: Option<String>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub vision_model: Option<String>,
611 #[serde(skip_serializing_if = "Option::is_none")]
613 pub reasoning_effort: Option<ReasoningEffort>,
614
615 #[serde(default, skip_serializing_if = "Vec::is_empty")]
622 pub responses_only_models: Vec<String>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub request_overrides: Option<RequestOverridesConfig>,
626
627 #[serde(default, flatten)]
629 pub extra: BTreeMap<String, Value>,
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct AnthropicConfig {
645 #[serde(default, skip_serializing)]
649 pub api_key: String,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub api_key_encrypted: Option<String>,
653 #[serde(skip_serializing_if = "Option::is_none")]
655 pub base_url: Option<String>,
656 #[serde(skip_serializing_if = "Option::is_none")]
658 pub model: Option<String>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub fast_model: Option<String>,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub vision_model: Option<String>,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub max_tokens: Option<u32>,
670 #[serde(skip_serializing_if = "Option::is_none")]
672 pub reasoning_effort: Option<ReasoningEffort>,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub request_overrides: Option<RequestOverridesConfig>,
676
677 #[serde(default, flatten)]
679 pub extra: BTreeMap<String, Value>,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct GeminiConfig {
694 #[serde(default, skip_serializing)]
698 pub api_key: String,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub api_key_encrypted: Option<String>,
702 #[serde(skip_serializing_if = "Option::is_none")]
704 pub base_url: Option<String>,
705 #[serde(skip_serializing_if = "Option::is_none")]
707 pub model: Option<String>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub fast_model: Option<String>,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub vision_model: Option<String>,
716 #[serde(skip_serializing_if = "Option::is_none")]
718 pub reasoning_effort: Option<ReasoningEffort>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub request_overrides: Option<RequestOverridesConfig>,
722
723 #[serde(default, flatten)]
725 pub extra: BTreeMap<String, Value>,
726}
727
728#[derive(Debug, Clone, Default, Serialize, Deserialize)]
740pub struct CopilotConfig {
741 #[serde(default)]
743 pub enabled: bool,
744 #[serde(default)]
746 pub headless_auth: bool,
747 #[serde(skip_serializing_if = "Option::is_none")]
749 pub model: Option<String>,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub fast_model: Option<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub vision_model: Option<String>,
758 #[serde(skip_serializing_if = "Option::is_none")]
760 pub reasoning_effort: Option<ReasoningEffort>,
761
762 #[serde(default, skip_serializing_if = "Vec::is_empty")]
771 pub responses_only_models: Vec<String>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub request_overrides: Option<RequestOverridesConfig>,
775
776 #[serde(default, flatten)]
778 pub extra: BTreeMap<String, Value>,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct BodhiConfig {
787 #[serde(default, skip_serializing)]
789 pub api_key: String,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub api_key_encrypted: Option<String>,
793 #[serde(skip_serializing_if = "Option::is_none")]
795 pub base_url: Option<String>,
796 #[serde(skip_serializing_if = "Option::is_none")]
798 pub target_provider: Option<String>,
799 #[serde(skip_serializing_if = "Option::is_none")]
801 pub reasoning_effort: Option<ReasoningEffort>,
802
803 #[serde(default, flatten)]
805 pub extra: BTreeMap<String, Value>,
806}
807
808fn default_provider() -> String {
810 "anthropic".to_string()
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
845pub struct ProviderInstanceConfig {
846 pub provider_type: String,
851
852 #[serde(default, skip_serializing_if = "Option::is_none")]
854 pub label: Option<String>,
855
856 #[serde(default, skip_serializing)]
858 pub api_key: String,
859
860 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub api_key_encrypted: Option<String>,
864
865 #[serde(default, skip_serializing_if = "Option::is_none")]
867 pub base_url: Option<String>,
868
869 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub model: Option<String>,
872
873 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub fast_model: Option<String>,
876
877 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub vision_model: Option<String>,
880
881 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
884
885 #[serde(default, skip_serializing_if = "Vec::is_empty")]
887 pub responses_only_models: Vec<String>,
888
889 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub request_overrides: Option<RequestOverridesConfig>,
892
893 #[serde(default = "default_true")]
896 pub enabled: bool,
897
898 #[serde(default, flatten)]
900 pub extra: BTreeMap<String, Value>,
901}
902
903fn default_true() -> bool {
904 true
905}
906
907fn default_port() -> u16 {
909 9562
910}
911
912fn default_bind() -> String {
914 "127.0.0.1".to_string()
915}
916
917fn default_workers() -> usize {
919 10
920}
921
922fn default_data_dir() -> PathBuf {
924 super::paths::bamboo_dir()
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
929pub struct ServerConfig {
930 #[serde(default = "default_port")]
932 pub port: u16,
933
934 #[serde(default = "default_bind")]
936 pub bind: String,
937
938 pub static_dir: Option<PathBuf>,
940
941 #[serde(default = "default_workers")]
943 pub workers: usize,
944
945 #[serde(default, flatten)]
947 pub extra: BTreeMap<String, Value>,
948}
949
950impl Default for ServerConfig {
951 fn default() -> Self {
952 Self {
953 port: default_port(),
954 bind: default_bind(),
955 static_dir: None,
956 workers: default_workers(),
957 extra: BTreeMap::new(),
958 }
959 }
960}
961
962#[derive(Debug, Clone, Serialize, Deserialize)]
964pub struct ProxyAuth {
965 pub username: String,
967 pub password: String,
969}
970
971fn parse_bool_env(value: &str) -> bool {
975 matches!(
976 value.trim().to_ascii_lowercase().as_str(),
977 "1" | "true" | "yes" | "y" | "on"
978 )
979}
980
981fn expand_user_path(value: &str) -> PathBuf {
982 let trimmed = value.trim();
983 if let Some(rest) = trimmed.strip_prefix("~/") {
984 if let Some(home) = dirs::home_dir() {
985 return home.join(rest);
986 }
987 }
988 PathBuf::from(trimmed)
989}
990
991impl Default for Config {
992 fn default() -> Self {
993 Self::new()
994 }
995}
996
997#[derive(Debug, Clone, PartialEq, Eq)]
999pub struct PromptSafeEnvVarEntry {
1000 pub name: String,
1001 pub secret: bool,
1002 pub description: Option<String>,
1003}
1004
1005static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
1009
1010static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
1011
1012fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
1013 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
1014}
1015
1016fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
1017 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
1018}
1019
1020impl Config {
1021 pub fn new() -> Self {
1040 Self::from_data_dir(None)
1041 }
1042
1043 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
1049 let data_dir = data_dir
1051 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
1052 .unwrap_or_else(default_data_dir);
1053
1054 let config_path = data_dir.join("config.json");
1055
1056 let mut config = if config_path.exists() {
1057 if let Ok(content) = std::fs::read_to_string(&config_path) {
1058 serde_json::from_str::<Config>(&content)
1059 .map(|mut config| {
1060 config.hydrate_proxy_auth_from_encrypted();
1061 config.hydrate_provider_api_keys_from_encrypted();
1062 config.hydrate_provider_instance_api_keys_from_encrypted();
1063 config.hydrate_mcp_secrets_from_encrypted();
1064 config.hydrate_env_vars_from_encrypted();
1065 config.normalize_tool_settings();
1066 config.normalize_skill_settings();
1067 config
1068 })
1069 .unwrap_or_else(|e| {
1070 tracing::warn!("Failed to parse config.json ({}), using defaults", e);
1071 Self::create_default()
1072 })
1073 } else {
1074 Self::create_default()
1075 }
1076 } else {
1077 Self::create_default()
1078 };
1079
1080 config.hydrate_proxy_auth_from_encrypted();
1082 config.hydrate_provider_api_keys_from_encrypted();
1084 config.hydrate_provider_instance_api_keys_from_encrypted();
1086 config.hydrate_mcp_secrets_from_encrypted();
1088 config.hydrate_env_vars_from_encrypted();
1090 config.normalize_tool_settings();
1091 config.normalize_skill_settings();
1092
1093 config.extra.remove("data_dir");
1096
1097 if let Ok(port) = std::env::var("BAMBOO_PORT") {
1099 if let Ok(port) = port.parse() {
1100 config.server.port = port;
1101 }
1102 }
1103
1104 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
1105 config.server.bind = bind;
1106 }
1107
1108 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
1110 config.provider = provider;
1111 }
1112
1113 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
1114 config.headless_auth = parse_bool_env(&headless);
1115 }
1116
1117 if let Ok(project_prompt_injection) =
1118 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
1119 {
1120 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1121 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
1122 }
1123
1124 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
1125 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1126 memory.relevant_recall = parse_bool_env(&relevant_recall);
1127 }
1128
1129 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
1130 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1131 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
1132 }
1133
1134 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
1135 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1136 memory.project_first_dream = parse_bool_env(&project_first_dream);
1137 }
1138
1139 config.publish_env_vars();
1141
1142 config
1143 }
1144
1145 pub fn get_model(&self) -> Option<String> {
1153 if self.features.provider_model_ref {
1154 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
1155 return Some(model_ref.model.clone());
1156 }
1157 }
1158 match self.provider.as_str() {
1159 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
1160 "anthropic" => self
1161 .providers
1162 .anthropic
1163 .as_ref()
1164 .and_then(|c| c.model.clone()),
1165 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
1166 "copilot" => Some(
1167 self.providers
1168 .copilot
1169 .as_ref()
1170 .and_then(|c| c.model.clone())
1171 .unwrap_or_else(|| "gpt-4o".to_string()),
1172 ),
1173 _ => None,
1174 }
1175 }
1176
1177 pub fn get_fast_model(&self) -> Option<String> {
1185 if self.features.provider_model_ref {
1186 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
1187 return Some(model_ref.model.clone());
1188 }
1189 }
1190 let fast = match self.provider.as_str() {
1191 "openai" => self
1192 .providers
1193 .openai
1194 .as_ref()
1195 .and_then(|c| c.fast_model.clone()),
1196 "anthropic" => self
1197 .providers
1198 .anthropic
1199 .as_ref()
1200 .and_then(|c| c.fast_model.clone()),
1201 "gemini" => self
1202 .providers
1203 .gemini
1204 .as_ref()
1205 .and_then(|c| c.fast_model.clone()),
1206 "copilot" => self
1207 .providers
1208 .copilot
1209 .as_ref()
1210 .and_then(|c| c.fast_model.clone()),
1211 _ => None,
1212 };
1213 fast.or_else(|| self.get_model())
1214 }
1215
1216 pub fn get_task_summary_model(&self) -> Option<String> {
1224 if self.features.provider_model_ref {
1225 if let Some(model_ref) = self
1226 .defaults
1227 .as_ref()
1228 .and_then(|d| d.task_summary.as_ref())
1229 .or_else(|| {
1230 self.defaults
1231 .as_ref()
1232 .and_then(|d| d.memory_background.as_ref())
1233 })
1234 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
1235 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
1236 {
1237 return Some(model_ref.model.clone());
1238 }
1239 }
1240
1241 self.get_memory_background_model()
1242 .or_else(|| self.get_model())
1243 }
1244
1245 pub fn get_memory_background_model(&self) -> Option<String> {
1257 if self.features.provider_model_ref {
1258 if let Some(model_ref) = self
1259 .defaults
1260 .as_ref()
1261 .and_then(|d| d.memory_background.as_ref())
1262 {
1263 return Some(model_ref.model.clone());
1264 }
1265 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
1266 return Some(model_ref.model.clone());
1267 }
1268 }
1269 let configured = self
1270 .memory
1271 .as_ref()
1272 .and_then(|memory| memory.background_model.as_ref())
1273 .map(|value| value.trim())
1274 .filter(|value| !value.is_empty())
1275 .map(ToString::to_string);
1276 configured.or_else(|| match self.provider.as_str() {
1277 "openai" => self
1278 .providers
1279 .openai
1280 .as_ref()
1281 .and_then(|c| c.fast_model.clone()),
1282 "anthropic" => self
1283 .providers
1284 .anthropic
1285 .as_ref()
1286 .and_then(|c| c.fast_model.clone()),
1287 "gemini" => self
1288 .providers
1289 .gemini
1290 .as_ref()
1291 .and_then(|c| c.fast_model.clone()),
1292 "copilot" => self
1293 .providers
1294 .copilot
1295 .as_ref()
1296 .and_then(|c| c.fast_model.clone()),
1297 _ => None,
1298 })
1299 }
1300
1301 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
1309 let raw = self
1310 .default_work_area
1311 .as_ref()
1312 .and_then(|config| config.path.as_ref())
1313 .map(|value| value.trim())
1314 .filter(|value| !value.is_empty())?;
1315
1316 let candidate = expand_user_path(raw);
1317 if candidate.is_absolute() {
1318 let canonical = std::fs::canonicalize(&candidate).ok();
1319 return canonical
1320 .as_ref()
1321 .filter(|path| path.is_dir())
1322 .map(|_| candidate.clone())
1323 .or_else(|| candidate.is_dir().then_some(candidate));
1324 }
1325
1326 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
1327 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
1328 canonical
1329 .as_ref()
1330 .filter(|path| path.is_dir())
1331 .map(|_| from_bamboo_dir.clone())
1332 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
1333 .or_else(|| candidate.is_dir().then_some(candidate))
1334 }
1335
1336 pub fn get_vision_model(&self) -> Option<String> {
1341 let vision = match self.provider.as_str() {
1342 "openai" => self
1343 .providers
1344 .openai
1345 .as_ref()
1346 .and_then(|c| c.vision_model.clone()),
1347 "anthropic" => self
1348 .providers
1349 .anthropic
1350 .as_ref()
1351 .and_then(|c| c.vision_model.clone()),
1352 "gemini" => self
1353 .providers
1354 .gemini
1355 .as_ref()
1356 .and_then(|c| c.vision_model.clone()),
1357 "copilot" => self
1358 .providers
1359 .copilot
1360 .as_ref()
1361 .and_then(|c| c.vision_model.clone()),
1362 _ => None,
1363 };
1364 vision.or_else(|| self.get_model())
1365 }
1366
1367 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
1369 match self.provider.as_str() {
1370 "openai" => self
1371 .providers
1372 .openai
1373 .as_ref()
1374 .and_then(|c| c.reasoning_effort),
1375 "anthropic" => self
1376 .providers
1377 .anthropic
1378 .as_ref()
1379 .and_then(|c| c.reasoning_effort),
1380 "gemini" => self
1381 .providers
1382 .gemini
1383 .as_ref()
1384 .and_then(|c| c.reasoning_effort),
1385 "copilot" => self
1386 .providers
1387 .copilot
1388 .as_ref()
1389 .and_then(|c| c.reasoning_effort),
1390 _ => None,
1391 }
1392 }
1393
1394 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
1396 self.tools
1397 .disabled
1398 .iter()
1399 .map(|name| name.trim())
1400 .filter(|name| !name.is_empty())
1401 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
1402 .collect()
1403 }
1404
1405 pub fn normalize_tool_settings(&mut self) {
1407 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
1408 }
1409
1410 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
1412 self.skills
1413 .disabled
1414 .iter()
1415 .map(|id| id.trim())
1416 .filter(|id| !id.is_empty())
1417 .map(|id| id.to_string())
1418 .collect()
1419 }
1420
1421 pub fn normalize_skill_settings(&mut self) {
1423 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
1424 }
1425
1426 pub fn effective_default_provider(&self) -> &str {
1431 self.default_provider_instance
1432 .as_deref()
1433 .unwrap_or(&self.provider)
1434 }
1435
1436 pub fn has_provider_instances(&self) -> bool {
1438 !self.provider_instances.is_empty()
1439 }
1440
1441 pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
1447 if self.proxy_auth.is_some() {
1448 return;
1449 }
1450
1451 if self
1458 .proxy_auth_encrypted
1459 .as_deref()
1460 .map(|s| s.trim().is_empty())
1461 .unwrap_or(true)
1462 {
1463 let legacy = self
1464 .extra
1465 .get("https_proxy_auth_encrypted")
1466 .and_then(|v| v.as_str())
1467 .or_else(|| {
1468 self.extra
1469 .get("http_proxy_auth_encrypted")
1470 .and_then(|v| v.as_str())
1471 })
1472 .map(|s| s.trim())
1473 .filter(|s| !s.is_empty())
1474 .map(|s| s.to_string());
1475
1476 if let Some(legacy) = legacy {
1477 self.proxy_auth_encrypted = Some(legacy);
1478 }
1479 }
1480
1481 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
1482 return;
1483 };
1484
1485 match crate::encryption::decrypt(encrypted) {
1486 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
1487 Ok(auth) => {
1488 self.proxy_auth = Some(auth);
1489 self.extra.remove("http_proxy_auth_encrypted");
1492 self.extra.remove("https_proxy_auth_encrypted");
1493 }
1494 Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
1495 },
1496 Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
1497 }
1498 }
1499
1500 pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
1505 let Some(auth) = self.proxy_auth.as_ref() else {
1509 self.proxy_auth_encrypted = None;
1510 return Ok(());
1511 };
1512
1513 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
1514 let encrypted =
1515 crate::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
1516 self.proxy_auth_encrypted = Some(encrypted);
1517 Ok(())
1518 }
1519
1520 pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
1521 if let Some(openai) = self.providers.openai.as_mut() {
1522 if openai.api_key.trim().is_empty() {
1523 if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
1524 match crate::encryption::decrypt(encrypted) {
1525 Ok(value) => openai.api_key = value,
1526 Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
1527 }
1528 }
1529 }
1530 }
1531
1532 if let Some(anthropic) = self.providers.anthropic.as_mut() {
1533 if anthropic.api_key.trim().is_empty() {
1534 if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
1535 match crate::encryption::decrypt(encrypted) {
1536 Ok(value) => anthropic.api_key = value,
1537 Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
1538 }
1539 }
1540 }
1541 }
1542
1543 if let Some(gemini) = self.providers.gemini.as_mut() {
1544 if gemini.api_key.trim().is_empty() {
1545 if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
1546 match crate::encryption::decrypt(encrypted) {
1547 Ok(value) => gemini.api_key = value,
1548 Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
1549 }
1550 }
1551 }
1552 }
1553
1554 if let Some(bodhi) = self.providers.bodhi.as_mut() {
1555 if bodhi.api_key.trim().is_empty() {
1556 if let Some(encrypted) = bodhi.api_key_encrypted.as_deref() {
1557 match crate::encryption::decrypt(encrypted) {
1558 Ok(value) => bodhi.api_key = value,
1559 Err(e) => tracing::warn!("Failed to decrypt Bodhi api_key: {}", e),
1560 }
1561 }
1562 }
1563 }
1564 }
1565
1566 pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
1567 if let Some(openai) = self.providers.openai.as_mut() {
1568 let api_key = openai.api_key.trim();
1569 openai.api_key_encrypted = if api_key.is_empty() {
1570 None
1571 } else {
1572 Some(
1573 crate::encryption::encrypt(api_key)
1574 .context("Failed to encrypt OpenAI api_key")?,
1575 )
1576 };
1577 }
1578
1579 if let Some(anthropic) = self.providers.anthropic.as_mut() {
1580 let api_key = anthropic.api_key.trim();
1581 anthropic.api_key_encrypted = if api_key.is_empty() {
1582 None
1583 } else {
1584 Some(
1585 crate::encryption::encrypt(api_key)
1586 .context("Failed to encrypt Anthropic api_key")?,
1587 )
1588 };
1589 }
1590
1591 if let Some(gemini) = self.providers.gemini.as_mut() {
1592 let api_key = gemini.api_key.trim();
1593 gemini.api_key_encrypted = if api_key.is_empty() {
1594 None
1595 } else {
1596 Some(
1597 crate::encryption::encrypt(api_key)
1598 .context("Failed to encrypt Gemini api_key")?,
1599 )
1600 };
1601 }
1602
1603 if let Some(bodhi) = self.providers.bodhi.as_mut() {
1604 let api_key = bodhi.api_key.trim();
1605 bodhi.api_key_encrypted = if api_key.is_empty() {
1606 None
1607 } else {
1608 Some(
1609 crate::encryption::encrypt(api_key)
1610 .context("Failed to encrypt Bodhi api_key")?,
1611 )
1612 };
1613 }
1614
1615 Ok(())
1616 }
1617
1618 pub fn hydrate_provider_instance_api_keys_from_encrypted(&mut self) {
1621 for (id, instance) in self.provider_instances.iter_mut() {
1622 if instance.api_key.trim().is_empty() {
1623 if let Some(encrypted) = instance.api_key_encrypted.as_deref() {
1624 match crate::encryption::decrypt(encrypted) {
1625 Ok(value) => instance.api_key = value,
1626 Err(e) => {
1627 tracing::warn!(instance_id = id, "Failed to decrypt api_key: {}", e)
1628 }
1629 }
1630 }
1631 }
1632 }
1633 }
1634
1635 pub fn refresh_provider_instance_api_keys_encrypted(&mut self) -> Result<()> {
1638 for (id, instance) in self.provider_instances.iter_mut() {
1639 let api_key = instance.api_key.trim();
1640 instance.api_key_encrypted = if api_key.is_empty() {
1641 None
1642 } else {
1643 Some(crate::encryption::encrypt(api_key).context(format!(
1644 "Failed to encrypt api_key for provider instance '{}'",
1645 id
1646 ))?)
1647 };
1648 }
1649 Ok(())
1650 }
1651
1652 pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
1653 for server in self.mcp.servers.iter_mut() {
1654 match &mut server.transport {
1655 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
1656 if stdio.env_encrypted.is_empty() {
1657 continue;
1658 }
1659
1660 for (key, encrypted) in stdio.env_encrypted.clone() {
1662 let should_hydrate = stdio
1663 .env
1664 .get(&key)
1665 .map(|v| v.trim().is_empty())
1666 .unwrap_or(true);
1667 if !should_hydrate {
1668 continue;
1669 }
1670
1671 match crate::encryption::decrypt(&encrypted) {
1672 Ok(value) => {
1673 stdio.env.insert(key, value);
1674 }
1675 Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
1676 }
1677 }
1678 }
1679 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
1680 for header in sse.headers.iter_mut() {
1681 if !header.value.trim().is_empty() {
1682 continue;
1683 }
1684 let Some(encrypted) = header.value_encrypted.as_deref() else {
1685 continue;
1686 };
1687 match crate::encryption::decrypt(encrypted) {
1688 Ok(value) => header.value = value,
1689 Err(e) => {
1690 tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
1691 }
1692 }
1693 }
1694 }
1695 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
1696 for header in sh.headers.iter_mut() {
1697 if !header.value.trim().is_empty() {
1698 continue;
1699 }
1700 let Some(encrypted) = header.value_encrypted.as_deref() else {
1701 continue;
1702 };
1703 match crate::encryption::decrypt(encrypted) {
1704 Ok(value) => header.value = value,
1705 Err(e) => {
1706 tracing::warn!(
1707 "Failed to decrypt MCP StreamableHTTP header value: {}",
1708 e
1709 )
1710 }
1711 }
1712 }
1713 }
1714 }
1715 }
1716 }
1717
1718 pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
1719 for server in self.mcp.servers.iter_mut() {
1720 match &mut server.transport {
1721 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
1722 stdio.env_encrypted.clear();
1723 for (key, value) in &stdio.env {
1724 let encrypted = crate::encryption::encrypt(value).with_context(|| {
1725 format!("Failed to encrypt MCP stdio env var '{key}'")
1726 })?;
1727 stdio.env_encrypted.insert(key.clone(), encrypted);
1728 }
1729 }
1730 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
1731 for header in sse.headers.iter_mut() {
1732 let configured = !header.value.trim().is_empty();
1733 header.value_encrypted = if !configured {
1734 None
1735 } else {
1736 Some(crate::encryption::encrypt(&header.value).with_context(|| {
1737 format!("Failed to encrypt MCP SSE header '{}'", header.name)
1738 })?)
1739 };
1740 }
1741 }
1742 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
1743 for header in sh.headers.iter_mut() {
1744 let configured = !header.value.trim().is_empty();
1745 header.value_encrypted = if !configured {
1746 None
1747 } else {
1748 Some(crate::encryption::encrypt(&header.value).with_context(|| {
1749 format!(
1750 "Failed to encrypt MCP StreamableHTTP header '{}'",
1751 header.name
1752 )
1753 })?)
1754 };
1755 }
1756 }
1757 }
1758 }
1759
1760 Ok(())
1761 }
1762
1763 pub fn hydrate_env_vars_from_encrypted(&mut self) {
1767 for entry in &mut self.env_vars {
1768 if !entry.secret {
1769 continue;
1770 }
1771 if !entry.value.trim().is_empty() {
1772 continue;
1774 }
1775 let Some(encrypted) = &entry.value_encrypted else {
1776 continue;
1777 };
1778 match crate::encryption::decrypt(encrypted) {
1779 Ok(value) => entry.value = value,
1780 Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
1781 }
1782 }
1783 }
1784
1785 pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
1787 for entry in &mut self.env_vars {
1788 if entry.secret && !entry.value.trim().is_empty() {
1789 entry.value_encrypted = Some(
1790 crate::encryption::encrypt(&entry.value)
1791 .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
1792 );
1793 } else if !entry.secret {
1794 entry.value_encrypted = None;
1795 }
1796 }
1797 Ok(())
1798 }
1799
1800 pub fn sanitize_env_vars_for_disk(&mut self) {
1802 for entry in &mut self.env_vars {
1803 if entry.secret {
1804 entry.value = String::new();
1805 }
1806 }
1807 }
1808
1809 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
1811 self.env_vars
1812 .iter()
1813 .filter(|e| !e.value.trim().is_empty())
1814 .map(|e| (e.name.clone(), e.value.clone()))
1815 .collect()
1816 }
1817
1818 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
1819 self.env_vars
1820 .iter()
1821 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
1822 .map(|entry| PromptSafeEnvVarEntry {
1823 name: entry.name.clone(),
1824 secret: entry.secret,
1825 description: entry
1826 .description
1827 .as_ref()
1828 .map(|value| value.trim().to_string())
1829 .filter(|value| !value.is_empty()),
1830 })
1831 .collect()
1832 }
1833
1834 pub fn publish_env_vars(&self) {
1836 let map = self.env_vars_as_map();
1837 let mut env_guard = env_vars_cache()
1838 .write()
1839 .unwrap_or_else(|poisoned| poisoned.into_inner());
1840 *env_guard = map;
1841
1842 let prompt_safe = self.prompt_safe_env_vars();
1843 let mut prompt_guard = prompt_safe_env_vars_cache()
1844 .write()
1845 .unwrap_or_else(|poisoned| poisoned.into_inner());
1846 *prompt_guard = prompt_safe;
1847 }
1848
1849 pub fn current_env_vars() -> HashMap<String, String> {
1851 env_vars_cache()
1852 .read()
1853 .unwrap_or_else(|poisoned| poisoned.into_inner())
1854 .clone()
1855 }
1856
1857 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
1859 prompt_safe_env_vars_cache()
1860 .read()
1861 .unwrap_or_else(|poisoned| poisoned.into_inner())
1862 .clone()
1863 }
1864
1865 fn create_default() -> Self {
1867 Config {
1868 http_proxy: String::new(),
1869 https_proxy: String::new(),
1870 proxy_auth: None,
1871 proxy_auth_encrypted: None,
1872 headless_auth: false,
1873 provider: default_provider(),
1874 providers: ProviderConfigs::default(),
1875 provider_instances: HashMap::new(),
1876 default_provider_instance: None,
1877 server: ServerConfig::default(),
1878 keyword_masking: KeywordMaskingConfig::default(),
1879 anthropic_model_mapping: AnthropicModelMapping::default(),
1880 gemini_model_mapping: GeminiModelMapping::default(),
1881 hooks: HooksConfig::default(),
1882 tools: ToolsConfig::default(),
1883 skills: SkillsConfig::default(),
1884 env_vars: Vec::new(),
1885 default_work_area: None,
1886 access_control: None,
1887 features: FeatureFlags::default(),
1888 defaults: None,
1889 memory: None,
1890 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1891 extra: BTreeMap::new(),
1892 }
1893 }
1894
1895 pub fn server_addr(&self) -> String {
1897 format!("{}:{}", self.server.bind, self.server.port)
1898 }
1899
1900 pub fn save(&self) -> Result<()> {
1902 self.save_to_dir(default_data_dir())
1903 }
1904
1905 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
1909 let path = data_dir.join("config.json");
1910
1911 if let Some(parent) = path.parent() {
1912 std::fs::create_dir_all(parent)
1913 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
1914 }
1915
1916 let mut to_save = self.clone();
1917 to_save.extra.remove("data_dir");
1919 to_save.extra.remove("model");
1921 to_save.refresh_proxy_auth_encrypted()?;
1922 to_save.refresh_provider_api_keys_encrypted()?;
1923 to_save.refresh_provider_instance_api_keys_encrypted()?;
1924 to_save.refresh_env_vars_encrypted()?;
1925 to_save.sanitize_env_vars_for_disk();
1926 to_save.normalize_tool_settings();
1927 to_save.normalize_skill_settings();
1928 let content =
1929 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
1930 write_atomic(&path, content.as_bytes())
1931 .with_context(|| format!("Failed to write config file: {:?}", path))?;
1932
1933 Ok(())
1934 }
1935}
1936
1937fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
1938 let Some(parent) = path.parent() else {
1939 return std::fs::write(path, content);
1940 };
1941
1942 std::fs::create_dir_all(parent)?;
1943
1944 let file_name = path
1947 .file_name()
1948 .and_then(|s| s.to_str())
1949 .unwrap_or("config.json");
1950 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1951 let tmp_path = parent.join(tmp_name);
1952
1953 {
1954 let mut file = std::fs::File::create(&tmp_path)?;
1955 file.write_all(content)?;
1956 file.sync_all()?;
1957 }
1958
1959 std::fs::rename(&tmp_path, path)?;
1960 Ok(())
1961}
1962
1963#[cfg(test)]
1964mod tests {
1965 use super::*;
1966 use std::ffi::OsString;
1967 use std::path::PathBuf;
1968 use std::sync::{Mutex, OnceLock};
1969 use std::time::{SystemTime, UNIX_EPOCH};
1970
1971 struct EnvVarGuard {
1972 key: &'static str,
1973 previous: Option<OsString>,
1974 }
1975
1976 impl EnvVarGuard {
1977 fn set(key: &'static str, value: &str) -> Self {
1978 let previous = std::env::var_os(key);
1979 std::env::set_var(key, value);
1980 Self { key, previous }
1981 }
1982
1983 fn unset(key: &'static str) -> Self {
1984 let previous = std::env::var_os(key);
1985 std::env::remove_var(key);
1986 Self { key, previous }
1987 }
1988 }
1989
1990 impl Drop for EnvVarGuard {
1991 fn drop(&mut self) {
1992 match &self.previous {
1993 Some(value) => std::env::set_var(self.key, value),
1994 None => std::env::remove_var(self.key),
1995 }
1996 }
1997 }
1998
1999 struct TempHome {
2000 path: PathBuf,
2001 }
2002
2003 impl TempHome {
2004 fn new() -> Self {
2005 let nanos = SystemTime::now()
2006 .duration_since(UNIX_EPOCH)
2007 .expect("clock should be after unix epoch")
2008 .as_nanos();
2009 let path = std::env::temp_dir().join(format!(
2010 "chat-core-config-test-{}-{}",
2011 std::process::id(),
2012 nanos
2013 ));
2014 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
2015 Self { path }
2016 }
2017
2018 fn set_config_json(&self, content: &str) {
2019 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
2022 std::fs::write(self.path.join("config.json"), content)
2023 .expect("failed to write config.json");
2024 }
2025 }
2026
2027 impl Drop for TempHome {
2028 fn drop(&mut self) {
2029 let _ = std::fs::remove_dir_all(&self.path);
2030 }
2031 }
2032
2033 fn env_lock() -> &'static Mutex<()> {
2034 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2035 LOCK.get_or_init(|| Mutex::new(()))
2036 }
2037
2038 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
2040 env_lock().lock().unwrap_or_else(|poisoned| {
2041 poisoned.into_inner()
2043 })
2044 }
2045
2046 #[test]
2047 fn parse_bool_env_true_values() {
2048 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
2049 assert!(parse_bool_env(value), "value {value:?} should be true");
2050 }
2051 }
2052
2053 #[test]
2054 fn parse_bool_env_false_values() {
2055 for value in ["0", "false", "no", "off", "", " "] {
2056 assert!(!parse_bool_env(value), "value {value:?} should be false");
2057 }
2058 }
2059
2060 #[test]
2061 fn config_new_ignores_http_proxy_env_vars() {
2062 let _lock = env_lock_acquire();
2063 let temp_home = TempHome::new();
2064 temp_home.set_config_json(
2065 r#"{
2066 "http_proxy": "",
2067 "https_proxy": ""
2068}"#,
2069 );
2070
2071 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
2072 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
2073
2074 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2075
2076 assert!(
2077 config.http_proxy.is_empty(),
2078 "config should ignore HTTP_PROXY env var"
2079 );
2080 assert!(
2081 config.https_proxy.is_empty(),
2082 "config should ignore HTTPS_PROXY env var"
2083 );
2084 }
2085
2086 #[test]
2087 fn config_new_loads_config_when_proxy_fields_omitted() {
2088 let _lock = env_lock_acquire();
2089 let temp_home = TempHome::new();
2090 temp_home.set_config_json(
2091 r#"{
2092 "provider": "openai",
2093 "providers": {
2094 "openai": {
2095 "api_key": "sk-test",
2096 "model": "gpt-4o"
2097 }
2098 }
2099}"#,
2100 );
2101
2102 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
2103 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
2104
2105 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2106
2107 assert_eq!(
2108 config
2109 .providers
2110 .openai
2111 .as_ref()
2112 .and_then(|c| c.model.as_deref()),
2113 Some("gpt-4o"),
2114 "config should load provider model from config file even when proxy fields are omitted"
2115 );
2116 assert!(config.http_proxy.is_empty());
2117 assert!(config.https_proxy.is_empty());
2118 }
2119
2120 #[test]
2121 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
2122 let _lock = crate::test_support::env_cache_lock_acquire();
2123 let mut config = Config::default();
2124 config.env_vars = vec![
2125 EnvVarEntry {
2126 name: "SECRET_TOKEN".to_string(),
2127 value: "top-secret".to_string(),
2128 secret: true,
2129 value_encrypted: None,
2130 description: Some("Service token".to_string()),
2131 },
2132 EnvVarEntry {
2133 name: "API_BASE".to_string(),
2134 value: "https://internal.example".to_string(),
2135 secret: false,
2136 value_encrypted: None,
2137 description: Some("Internal API base".to_string()),
2138 },
2139 ];
2140
2141 config.publish_env_vars();
2142
2143 let injected = Config::current_env_vars();
2144 assert_eq!(
2145 injected.get("SECRET_TOKEN").map(String::as_str),
2146 Some("top-secret")
2147 );
2148 assert_eq!(
2149 injected.get("API_BASE").map(String::as_str),
2150 Some("https://internal.example")
2151 );
2152
2153 let prompt_safe = Config::current_prompt_safe_env_vars();
2154 assert_eq!(prompt_safe.len(), 2);
2155 assert!(prompt_safe.iter().any(|entry| {
2156 entry.name == "SECRET_TOKEN"
2157 && entry.secret
2158 && entry.description.as_deref() == Some("Service token")
2159 }));
2160 assert!(prompt_safe.iter().any(|entry| {
2161 entry.name == "API_BASE"
2162 && !entry.secret
2163 && entry.description.as_deref() == Some("Internal API base")
2164 }));
2165 assert!(!prompt_safe
2166 .iter()
2167 .any(|entry| entry.name.contains("top-secret")));
2168 assert!(!prompt_safe.iter().any(|entry| {
2169 entry
2170 .description
2171 .as_deref()
2172 .is_some_and(|value| value.contains("https://internal.example"))
2173 }));
2174 }
2175
2176 #[test]
2177 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
2178 let _lock = env_lock_acquire();
2179 let temp_home = TempHome::new();
2180 temp_home.set_config_json(
2181 r#"{
2182 "provider": "openai",
2183 "providers": {
2184 "openai": {
2185 "api_key": "sk-test",
2186 "model": "gpt-4o"
2187 }
2188 }
2189}"#,
2190 );
2191
2192 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
2193 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
2194
2195 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2196
2197 assert_eq!(
2198 config
2199 .providers
2200 .openai
2201 .as_ref()
2202 .and_then(|c| c.model.as_deref()),
2203 Some("gpt-4o")
2204 );
2205 assert!(
2206 config.http_proxy.is_empty(),
2207 "config should keep http_proxy empty when field is omitted"
2208 );
2209 assert!(
2210 config.https_proxy.is_empty(),
2211 "config should keep https_proxy empty when field is omitted"
2212 );
2213 }
2214
2215 #[test]
2216 fn get_memory_background_model_prefers_memory_specific_override() {
2217 let mut config = Config::default();
2218 config.features.provider_model_ref = false;
2219 config.provider = "openai".to_string();
2220 config.providers.openai = Some(OpenAIConfig {
2221 api_key: "test".to_string(),
2222 api_key_encrypted: None,
2223 base_url: None,
2224 model: Some("gpt-main".to_string()),
2225 fast_model: Some("gpt-fast".to_string()),
2226 vision_model: None,
2227 reasoning_effort: None,
2228 responses_only_models: vec![],
2229 request_overrides: None,
2230 extra: BTreeMap::new(),
2231 });
2232 config.memory = Some(MemoryConfig {
2233 background_model: Some("memory-fast".to_string()),
2234 ..MemoryConfig::default()
2235 });
2236
2237 assert_eq!(
2238 config.get_memory_background_model().as_deref(),
2239 Some("memory-fast")
2240 );
2241 }
2242
2243 #[test]
2244 fn get_memory_background_model_falls_back_to_provider_fast_model() {
2245 let mut config = Config::default();
2246 config.features.provider_model_ref = false;
2247 config.provider = "openai".to_string();
2248 config.providers.openai = Some(OpenAIConfig {
2249 api_key: "test".to_string(),
2250 api_key_encrypted: None,
2251 base_url: None,
2252 model: Some("gpt-main".to_string()),
2253 fast_model: Some("gpt-fast".to_string()),
2254 vision_model: None,
2255 reasoning_effort: None,
2256 responses_only_models: vec![],
2257 request_overrides: None,
2258 extra: BTreeMap::new(),
2259 });
2260
2261 assert_eq!(
2262 config.get_memory_background_model().as_deref(),
2263 Some("gpt-fast")
2264 );
2265 }
2266
2267 #[test]
2268 fn get_memory_background_model_does_not_fall_back_to_main_model() {
2269 let mut config = Config::default();
2270 config.features.provider_model_ref = false;
2271 config.provider = "openai".to_string();
2272 config.providers.openai = Some(OpenAIConfig {
2273 api_key: "test".to_string(),
2274 api_key_encrypted: None,
2275 base_url: None,
2276 model: Some("gpt-main".to_string()),
2277 fast_model: None,
2278 vision_model: None,
2279 reasoning_effort: None,
2280 responses_only_models: vec![],
2281 request_overrides: None,
2282 extra: BTreeMap::new(),
2283 });
2284
2285 assert!(config.get_memory_background_model().is_none());
2286 }
2287
2288 #[test]
2289 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
2290 let config = Config {
2291 memory: Some(MemoryConfig {
2292 background_model: Some("dream-fast".to_string()),
2293 auto_dream_enabled: true,
2294 project_prompt_injection: false,
2295 relevant_recall: false,
2296 relevant_recall_rerank: true,
2297 project_first_dream: false,
2298 dream_refine_mode: true,
2299 ..MemoryConfig::default()
2300 }),
2301 ..Config::default()
2302 };
2303
2304 let serialized = serde_json::to_string(&config).expect("config should serialize");
2305 let roundtrip: Config =
2306 serde_json::from_str(&serialized).expect("config should deserialize");
2307 let memory = roundtrip.memory.expect("memory config should exist");
2308 assert!(memory.auto_dream_enabled);
2309 assert!(!memory.project_prompt_injection);
2310 assert!(!memory.relevant_recall);
2311 assert!(memory.relevant_recall_rerank);
2312 assert!(!memory.project_first_dream);
2313 assert!(memory.dream_refine_mode);
2314 }
2315
2316 #[test]
2317 fn memory_config_env_overrides_prompt_flags() {
2318 let _lock = env_lock_acquire();
2319 let temp_home = TempHome::new();
2320 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2321 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
2322 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
2323 let _relevant_recall_rerank =
2324 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
2325 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
2326
2327 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2328 let memory = config
2329 .memory
2330 .expect("memory config should be created by env overrides");
2331 assert!(!memory.project_prompt_injection);
2332 assert!(!memory.relevant_recall);
2333 assert!(memory.relevant_recall_rerank);
2334 assert!(!memory.project_first_dream);
2335 }
2336
2337 #[test]
2338 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
2339 let _lock = env_lock_acquire();
2340 let temp_home = TempHome::new();
2341 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2342 let target = temp_home.path.join("workspace-default");
2343 std::fs::create_dir_all(&target).expect("default work area dir should exist");
2344
2345 let mut config = Config::default();
2346 config.default_work_area = Some(DefaultWorkAreaConfig {
2347 path: Some("~/workspace-default".to_string()),
2348 });
2349
2350 assert_eq!(config.get_default_work_area_path(), Some(target));
2351 }
2352
2353 #[test]
2354 fn get_default_work_area_path_returns_none_for_missing_directory() {
2355 let _lock = env_lock_acquire();
2356 let temp_home = TempHome::new();
2357 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2358
2359 let mut config = Config::default();
2360 config.default_work_area = Some(DefaultWorkAreaConfig {
2361 path: Some("~/missing-default-work-area".to_string()),
2362 });
2363
2364 assert!(config.get_default_work_area_path().is_none());
2365 }
2366
2367 #[test]
2368 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
2369 let mut config = Config::default();
2370 config.tools.disabled = vec![
2371 " read_file ".to_string(),
2372 "".to_string(),
2373 "read_file".to_string(),
2374 "bash".to_string(),
2375 "default::getCurrentDir".to_string(),
2376 ];
2377
2378 config.normalize_tool_settings();
2379
2380 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
2381 }
2382
2383 #[test]
2384 fn config_load_reads_disabled_tools_as_canonical_names() {
2385 let _lock = env_lock_acquire();
2386 let temp_home = TempHome::new();
2387 temp_home.set_config_json(
2388 r#"{
2389 "tools": {
2390 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
2391 }
2392}"#,
2393 );
2394
2395 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2396 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
2397 assert!(config.disabled_tool_names().contains("Bash"));
2398 assert!(config.disabled_tool_names().contains("Read"));
2399 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
2400 }
2401
2402 #[test]
2403 fn normalize_skill_settings_trims_dedupes_and_sorts() {
2404 let mut config = Config::default();
2405 config.skills.disabled = vec![
2406 " pdf ".to_string(),
2407 "".to_string(),
2408 "pdf".to_string(),
2409 "skill-creator".to_string(),
2410 ];
2411
2412 config.normalize_skill_settings();
2413
2414 assert_eq!(
2415 config.skills.disabled,
2416 vec!["pdf".to_string(), "skill-creator".to_string()]
2417 );
2418 }
2419
2420 #[test]
2421 fn config_load_reads_disabled_skills_as_normalized_ids() {
2422 let _lock = env_lock_acquire();
2423 let temp_home = TempHome::new();
2424 temp_home.set_config_json(
2425 r#"{
2426 "skills": {
2427 "disabled": [" pdf ", "skill-creator", "pdf", ""]
2428 }
2429}"#,
2430 );
2431
2432 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2433 assert_eq!(
2434 config.skills.disabled,
2435 vec!["pdf".to_string(), "skill-creator".to_string()]
2436 );
2437 assert!(config.disabled_skill_ids().contains("pdf"));
2438 assert!(config.disabled_skill_ids().contains("skill-creator"));
2439 }
2440
2441 #[test]
2442 fn test_server_config_defaults() {
2443 let _lock = env_lock_acquire();
2444 let temp_home = TempHome::new();
2445
2446 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2447 assert_eq!(config.server.port, 9562);
2448 assert_eq!(config.server.bind, "127.0.0.1");
2449 assert_eq!(config.server.workers, 10);
2450 assert!(config.server.static_dir.is_none());
2451 }
2452
2453 #[test]
2454 fn test_server_addr() {
2455 let mut config = Config::default();
2456 config.server.port = 9000;
2457 config.server.bind = "0.0.0.0".to_string();
2458 assert_eq!(config.server_addr(), "0.0.0.0:9000");
2459 }
2460
2461 #[test]
2462 fn test_env_var_overrides() {
2463 let _lock = env_lock_acquire();
2464 let temp_home = TempHome::new();
2465
2466 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
2467 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
2468 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
2469
2470 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2471 assert_eq!(config.server.port, 9999);
2472 assert_eq!(config.server.bind, "192.168.1.1");
2473 assert_eq!(config.provider, "openai");
2474 }
2475
2476 #[test]
2477 fn test_config_save_and_load() {
2478 let _lock = env_lock_acquire();
2479 let temp_home = TempHome::new();
2480
2481 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2482 config.server.port = 9000;
2483 config.server.bind = "0.0.0.0".to_string();
2484 config.provider = "anthropic".to_string();
2485
2486 config
2488 .save_to_dir(temp_home.path.clone())
2489 .expect("Failed to save config");
2490
2491 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2493
2494 assert_eq!(loaded.server.port, 9000);
2496 assert_eq!(loaded.server.bind, "0.0.0.0");
2497 assert_eq!(loaded.provider, "anthropic");
2498 }
2499
2500 #[test]
2501 fn config_decrypts_proxy_auth_from_encrypted_field() {
2502 let _lock = env_lock_acquire();
2503 let temp_home = TempHome::new();
2504
2505 let key_guard = crate::encryption::set_test_encryption_key([
2507 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2508 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2509 0x1c, 0x1d, 0x1e, 0x1f,
2510 ]);
2511
2512 let auth = ProxyAuth {
2513 username: "user".to_string(),
2514 password: "pass".to_string(),
2515 };
2516 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
2517 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
2518
2519 temp_home.set_config_json(&format!(
2520 r#"{{
2521 "http_proxy": "http://proxy.example.com:8080",
2522 "proxy_auth_encrypted": "{encrypted}"
2523}}"#
2524 ));
2525 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2526 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
2527 assert_eq!(loaded_auth.username, "user");
2528 assert_eq!(loaded_auth.password, "pass");
2529 drop(key_guard);
2530 }
2531
2532 #[test]
2533 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
2534 let _lock = env_lock_acquire();
2535 let temp_home = TempHome::new();
2536
2537 let key_guard = crate::encryption::set_test_encryption_key([
2539 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2540 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2541 0x1c, 0x1d, 0x1e, 0x1f,
2542 ]);
2543
2544 let auth = ProxyAuth {
2545 username: "user".to_string(),
2546 password: "pass".to_string(),
2547 };
2548 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
2549 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
2550
2551 temp_home.set_config_json(&format!(
2553 r#"{{
2554 "http_proxy": "http://proxy.example.com:8080",
2555 "http_proxy_auth_encrypted": "{encrypted}",
2556 "https_proxy_auth_encrypted": "{encrypted}"
2557}}"#
2558 ));
2559
2560 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2561 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
2562 assert_eq!(loaded_auth.username, "user");
2563 assert_eq!(loaded_auth.password, "pass");
2564 drop(key_guard);
2565 }
2566
2567 #[test]
2568 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
2569 let _lock = env_lock_acquire();
2570 let temp_home = TempHome::new();
2571
2572 let key_guard = crate::encryption::set_test_encryption_key([
2574 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2575 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2576 0x1c, 0x1d, 0x1e, 0x1f,
2577 ]);
2578
2579 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2580 config.proxy_auth = Some(ProxyAuth {
2581 username: "user".to_string(),
2582 password: "pass".to_string(),
2583 });
2584 config
2585 .save_to_dir(temp_home.path.clone())
2586 .expect("save should encrypt proxy auth");
2587
2588 let content =
2589 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2590 assert!(
2591 content.contains("proxy_auth_encrypted"),
2592 "config.json should store encrypted proxy auth"
2593 );
2594 assert!(
2595 !content.contains("\"proxy_auth\""),
2596 "config.json should not store plaintext proxy_auth"
2597 );
2598
2599 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2600 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
2601 assert_eq!(loaded_auth.username, "user");
2602 assert_eq!(loaded_auth.password, "pass");
2603 drop(key_guard);
2604 }
2605
2606 #[test]
2607 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
2608 let _lock = env_lock_acquire();
2609 let temp_home = TempHome::new();
2610
2611 let key_guard = crate::encryption::set_test_encryption_key([
2613 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2614 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2615 0x1c, 0x1d, 0x1e, 0x1f,
2616 ]);
2617
2618 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2619 config.provider = "openai".to_string();
2620 config.providers.openai = Some(OpenAIConfig {
2621 api_key: "sk-test-provider-key".to_string(),
2622 api_key_encrypted: None,
2623 base_url: None,
2624 model: None,
2625 fast_model: None,
2626 vision_model: None,
2627 reasoning_effort: None,
2628 responses_only_models: vec![],
2629 request_overrides: None,
2630 extra: Default::default(),
2631 });
2632
2633 config
2634 .save_to_dir(temp_home.path.clone())
2635 .expect("save should encrypt provider api keys");
2636
2637 let content =
2638 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2639 assert!(
2640 content.contains("\"api_key_encrypted\""),
2641 "config.json should store encrypted provider keys"
2642 );
2643 assert!(
2644 !content.contains("\"api_key\""),
2645 "config.json should not store plaintext provider keys"
2646 );
2647
2648 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2649 let openai = loaded
2650 .providers
2651 .openai
2652 .expect("openai config should be present");
2653 assert_eq!(openai.api_key, "sk-test-provider-key");
2654
2655 drop(key_guard);
2656 }
2657
2658 #[test]
2659 fn config_save_persists_mcp_servers_in_mainstream_format() {
2660 let _lock = env_lock_acquire();
2661 let temp_home = TempHome::new();
2662
2663 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2664
2665 let mut env = std::collections::HashMap::new();
2666 env.insert("TOKEN".to_string(), "supersecret".to_string());
2667
2668 config.mcp.servers = vec![
2669 bamboo_domain::mcp_config::McpServerConfig {
2670 id: "stdio-secret".to_string(),
2671 name: None,
2672 enabled: true,
2673 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
2674 bamboo_domain::mcp_config::StdioConfig {
2675 command: "echo".to_string(),
2676 args: vec![],
2677 cwd: None,
2678 env,
2679 env_encrypted: std::collections::HashMap::new(),
2680 startup_timeout_ms: 5000,
2681 },
2682 ),
2683 request_timeout_ms: 5000,
2684 healthcheck_interval_ms: 1000,
2685 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
2686 allowed_tools: vec![],
2687 denied_tools: vec![],
2688 },
2689 bamboo_domain::mcp_config::McpServerConfig {
2690 id: "sse-secret".to_string(),
2691 name: None,
2692 enabled: true,
2693 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
2694 bamboo_domain::mcp_config::SseConfig {
2695 url: "http://localhost:8080/sse".to_string(),
2696 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
2697 name: "Authorization".to_string(),
2698 value: "Bearer token123".to_string(),
2699 value_encrypted: None,
2700 }],
2701 connect_timeout_ms: 5000,
2702 },
2703 ),
2704 request_timeout_ms: 5000,
2705 healthcheck_interval_ms: 1000,
2706 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
2707 allowed_tools: vec![],
2708 denied_tools: vec![],
2709 },
2710 ];
2711
2712 config
2713 .save_to_dir(temp_home.path.clone())
2714 .expect("save should persist MCP servers");
2715
2716 let content =
2717 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2718 assert!(
2719 content.contains("\"mcpServers\""),
2720 "config.json should store MCP servers under the mainstream 'mcpServers' key"
2721 );
2722 assert!(
2723 content.contains("supersecret"),
2724 "config.json should persist MCP stdio env in mainstream format"
2725 );
2726 assert!(
2727 content.contains("Bearer token123"),
2728 "config.json should persist MCP SSE headers in mainstream format"
2729 );
2730 assert!(
2731 !content.contains("\"env_encrypted\""),
2732 "config.json should not persist legacy env_encrypted fields"
2733 );
2734 assert!(
2735 !content.contains("\"value_encrypted\""),
2736 "config.json should not persist legacy value_encrypted fields"
2737 );
2738
2739 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2740 let stdio = loaded
2741 .mcp
2742 .servers
2743 .iter()
2744 .find(|s| s.id == "stdio-secret")
2745 .expect("stdio server should exist");
2746 match &stdio.transport {
2747 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
2748 assert_eq!(
2749 stdio.env.get("TOKEN").map(|s| s.as_str()),
2750 Some("supersecret")
2751 );
2752 }
2753 _ => panic!("Expected stdio transport"),
2754 }
2755
2756 let sse = loaded
2757 .mcp
2758 .servers
2759 .iter()
2760 .find(|s| s.id == "sse-secret")
2761 .expect("sse server should exist");
2762 match &sse.transport {
2763 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
2764 assert_eq!(sse.headers[0].value, "Bearer token123");
2765 }
2766 _ => panic!("Expected SSE transport"),
2767 }
2768 }
2769
2770 #[test]
2773 fn env_vars_as_map_includes_only_non_empty_values() {
2774 let mut config = Config::default();
2775 config.env_vars = vec![
2776 EnvVarEntry {
2777 name: "A".to_string(),
2778 value: "val_a".to_string(),
2779 secret: false,
2780 value_encrypted: None,
2781 description: None,
2782 },
2783 EnvVarEntry {
2784 name: "B".to_string(),
2785 value: "".to_string(), secret: true,
2787 value_encrypted: None,
2788 description: None,
2789 },
2790 EnvVarEntry {
2791 name: "C".to_string(),
2792 value: " ".to_string(), secret: false,
2794 value_encrypted: None,
2795 description: None,
2796 },
2797 EnvVarEntry {
2798 name: "D".to_string(),
2799 value: "val_d".to_string(),
2800 secret: true,
2801 value_encrypted: Some("enc".to_string()),
2802 description: Some("desc".to_string()),
2803 },
2804 ];
2805
2806 let map = config.env_vars_as_map();
2807 assert_eq!(map.len(), 2);
2808 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
2809 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
2810 assert!(!map.contains_key("B"));
2811 assert!(!map.contains_key("C"));
2812 }
2813
2814 #[test]
2815 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
2816 let mut config = Config::default();
2817 config.env_vars = vec![
2818 EnvVarEntry {
2819 name: "PLAIN".to_string(),
2820 value: "visible".to_string(),
2821 secret: false,
2822 value_encrypted: None,
2823 description: None,
2824 },
2825 EnvVarEntry {
2826 name: "SECRET".to_string(),
2827 value: "hidden_value".to_string(),
2828 secret: true,
2829 value_encrypted: Some("enc_data".to_string()),
2830 description: None,
2831 },
2832 ];
2833
2834 config.sanitize_env_vars_for_disk();
2835
2836 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
2839
2840 #[test]
2841 fn sanitize_env_vars_for_disk_preserves_encrypted() {
2842 let mut config = Config::default();
2843 config.env_vars = vec![
2844 EnvVarEntry {
2845 name: "OPEN".to_string(),
2846 value: "val".to_string(),
2847 secret: false,
2848 value_encrypted: None,
2849 description: None,
2850 },
2851 EnvVarEntry {
2852 name: "HIDDEN".to_string(),
2853 value: "real_secret".to_string(),
2854 secret: true,
2855 value_encrypted: Some("enc".to_string()),
2856 description: None,
2857 },
2858 ];
2859
2860 config.sanitize_env_vars_for_disk();
2861
2862 assert_eq!(config.env_vars[0].value, "val");
2864 assert_eq!(config.env_vars[1].value, "");
2866 assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
2867 }
2868
2869 #[test]
2870 fn refresh_env_vars_encrypted_round_trip() {
2871 let mut config = Config::default();
2872 config.env_vars = vec![
2873 EnvVarEntry {
2874 name: "TOKEN".to_string(),
2875 value: "my-secret-token".to_string(),
2876 secret: true,
2877 value_encrypted: None,
2878 description: Some("A token".to_string()),
2879 },
2880 EnvVarEntry {
2881 name: "PLAIN_VAR".to_string(),
2882 value: "hello".to_string(),
2883 secret: false,
2884 value_encrypted: None,
2885 description: None,
2886 },
2887 ];
2888
2889 config
2891 .refresh_env_vars_encrypted()
2892 .expect("encryption should succeed");
2893
2894 assert!(config.env_vars[0].value_encrypted.is_some());
2896 assert!(config.env_vars[1].value_encrypted.is_none());
2898
2899 let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
2901 assert_ne!(encrypted, "my-secret-token"); config.sanitize_env_vars_for_disk();
2905 assert_eq!(config.env_vars[0].value, "");
2906
2907 config.hydrate_env_vars_from_encrypted();
2909 assert_eq!(config.env_vars[0].value, "my-secret-token");
2910 assert_eq!(config.env_vars[1].value, "hello"); }
2912
2913 #[test]
2914 fn publish_and_current_env_vars_round_trip() {
2915 let mut config = Config::default();
2916 config.env_vars = vec![EnvVarEntry {
2917 name: "TEST_PUBLISH".to_string(),
2918 value: "pub_value".to_string(),
2919 secret: false,
2920 value_encrypted: None,
2921 description: None,
2922 }];
2923
2924 for _ in 0..10 {
2925 config.publish_env_vars();
2926 let map = Config::current_env_vars();
2927 if map.get("TEST_PUBLISH") == Some(&"pub_value".to_string()) {
2928 return;
2929 }
2930 }
2931 panic!("TEST_PUBLISH not found in cache after retries");
2932 }
2933
2934 #[test]
2935 fn hydrate_skips_non_secret_entries() {
2936 let mut config = Config::default();
2937 config.env_vars = vec![EnvVarEntry {
2938 name: "PLAIN".to_string(),
2939 value: "original".to_string(),
2940 secret: false,
2941 value_encrypted: Some("should-be-ignored".to_string()),
2942 description: None,
2943 }];
2944
2945 config.hydrate_env_vars_from_encrypted();
2946 assert_eq!(config.env_vars[0].value, "original");
2948 }
2949
2950 #[test]
2951 fn default_config_has_empty_env_vars() {
2952 let config = Config::default();
2953 assert!(config.env_vars.is_empty());
2954 }
2955
2956 #[test]
2957 fn serde_round_trip_with_env_vars() {
2958 let mut config = Config::default();
2959 config.env_vars = vec![
2960 EnvVarEntry {
2961 name: "KEY1".to_string(),
2962 value: "val1".to_string(),
2963 secret: false,
2964 value_encrypted: None,
2965 description: Some("First key".to_string()),
2966 },
2967 EnvVarEntry {
2968 name: "KEY2".to_string(),
2969 value: "".to_string(), secret: true,
2971 value_encrypted: Some("enc123".to_string()),
2972 description: None,
2973 },
2974 ];
2975
2976 let json = serde_json::to_string(&config).unwrap();
2977 let restored: Config = serde_json::from_str(&json).unwrap();
2978
2979 assert_eq!(restored.env_vars.len(), 2);
2980 assert_eq!(restored.env_vars[0].name, "KEY1");
2981 assert_eq!(restored.env_vars[0].value, "val1");
2982 assert!(!restored.env_vars[0].secret);
2983 assert_eq!(restored.env_vars[1].name, "KEY2");
2984 assert!(restored.env_vars[1].secret);
2985 assert_eq!(
2986 restored.env_vars[1].value_encrypted.as_deref(),
2987 Some("enc123")
2988 );
2989 }
2990
2991 #[test]
2994 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
2995 let mut config = Config::default();
2996 config.provider = "openai".to_string();
2997 config.providers.openai = Some(OpenAIConfig {
2998 api_key: "test".to_string(),
2999 api_key_encrypted: None,
3000 base_url: None,
3001 model: Some("legacy-gpt-4o".to_string()),
3002 fast_model: None,
3003 vision_model: None,
3004 reasoning_effort: None,
3005 responses_only_models: vec![],
3006 request_overrides: None,
3007 extra: Default::default(),
3008 });
3009 config.features.provider_model_ref = true;
3010 config.defaults = Some(DefaultsConfig {
3011 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3012 fast: None,
3013 vision: None,
3014 memory_background: None,
3015 planning: None,
3016 search: None,
3017 code_review: None,
3018 sub_agent: None,
3019 subagent_models: Default::default(),
3020 });
3021
3022 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
3023 }
3024
3025 #[test]
3026 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
3027 let mut config = Config::default();
3028 config.provider = "openai".to_string();
3029 config.providers.openai = Some(OpenAIConfig {
3030 api_key: "test".to_string(),
3031 api_key_encrypted: None,
3032 base_url: None,
3033 model: Some("legacy-gpt-4o".to_string()),
3034 fast_model: None,
3035 vision_model: None,
3036 reasoning_effort: None,
3037 responses_only_models: vec![],
3038 request_overrides: None,
3039 extra: Default::default(),
3040 });
3041 config.features.provider_model_ref = false;
3042 config.defaults = Some(DefaultsConfig {
3043 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3044 fast: None,
3045 vision: None,
3046 memory_background: None,
3047 planning: None,
3048 search: None,
3049 code_review: None,
3050 sub_agent: None,
3051 subagent_models: Default::default(),
3052 });
3053
3054 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
3055 }
3056
3057 #[test]
3058 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
3059 let mut config = Config::default();
3060 config.provider = "openai".to_string();
3061 config.providers.openai = Some(OpenAIConfig {
3062 api_key: "test".to_string(),
3063 api_key_encrypted: None,
3064 base_url: None,
3065 model: Some("gpt-4o".to_string()),
3066 fast_model: Some("legacy-gpt-4o-mini".to_string()),
3067 vision_model: None,
3068 reasoning_effort: None,
3069 responses_only_models: vec![],
3070 request_overrides: None,
3071 extra: Default::default(),
3072 });
3073 config.features.provider_model_ref = true;
3074 config.defaults = Some(DefaultsConfig {
3075 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3076 fast: Some(bamboo_domain::ProviderModelRef::new(
3077 "anthropic",
3078 "claude-3-5-haiku",
3079 )),
3080 vision: None,
3081 memory_background: None,
3082 planning: None,
3083 search: None,
3084 code_review: None,
3085 sub_agent: None,
3086 subagent_models: Default::default(),
3087 });
3088
3089 assert_eq!(
3090 config.get_fast_model(),
3091 Some("claude-3-5-haiku".to_string())
3092 );
3093 }
3094
3095 #[test]
3096 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
3097 let mut config = Config::default();
3098 config.provider = "openai".to_string();
3099 config.providers.openai = Some(OpenAIConfig {
3100 api_key: "test".to_string(),
3101 api_key_encrypted: None,
3102 base_url: None,
3103 model: Some("gpt-4o".to_string()),
3104 fast_model: Some("legacy-gpt-4o-mini".to_string()),
3105 vision_model: None,
3106 reasoning_effort: None,
3107 responses_only_models: vec![],
3108 request_overrides: None,
3109 extra: Default::default(),
3110 });
3111 config.features.provider_model_ref = false;
3112 config.defaults = Some(DefaultsConfig {
3113 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3114 fast: Some(bamboo_domain::ProviderModelRef::new(
3115 "anthropic",
3116 "claude-3-5-haiku",
3117 )),
3118 vision: None,
3119 memory_background: None,
3120 planning: None,
3121 search: None,
3122 code_review: None,
3123 sub_agent: None,
3124 subagent_models: Default::default(),
3125 });
3126
3127 assert_eq!(
3128 config.get_fast_model(),
3129 Some("legacy-gpt-4o-mini".to_string())
3130 );
3131 }
3132
3133 #[test]
3134 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
3135 let mut config = Config::default();
3136 config.provider = "openai".to_string();
3137 config.features.provider_model_ref = true;
3138 config.defaults = Some(DefaultsConfig {
3139 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3140 fast: None,
3141 vision: None,
3142 memory_background: None,
3143 planning: None,
3144 search: None,
3145 code_review: None,
3146 sub_agent: None,
3147 subagent_models: Default::default(),
3148 });
3149
3150 assert_eq!(
3151 config.get_fast_model(),
3152 Some("claude-3-7-sonnet".to_string())
3153 );
3154 }
3155
3156 #[test]
3157 fn get_memory_background_model_prefers_defaults_memory_background() {
3158 let mut config = Config::default();
3159 config.provider = "openai".to_string();
3160 config.providers.openai = Some(OpenAIConfig {
3161 api_key: "test".to_string(),
3162 api_key_encrypted: None,
3163 base_url: None,
3164 model: Some("gpt-4o".to_string()),
3165 fast_model: Some("gpt-4o-mini".to_string()),
3166 vision_model: None,
3167 reasoning_effort: None,
3168 responses_only_models: vec![],
3169 request_overrides: None,
3170 extra: Default::default(),
3171 });
3172 config.features.provider_model_ref = true;
3173 config.defaults = Some(DefaultsConfig {
3174 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3175 fast: Some(bamboo_domain::ProviderModelRef::new(
3176 "openai",
3177 "gpt-4o-mini",
3178 )),
3179 vision: None,
3180 memory_background: Some(bamboo_domain::ProviderModelRef::new(
3181 "anthropic",
3182 "claude-3-5-haiku",
3183 )),
3184 planning: None,
3185 search: None,
3186 code_review: None,
3187 sub_agent: None,
3188 subagent_models: Default::default(),
3189 });
3190
3191 assert_eq!(
3192 config.get_memory_background_model(),
3193 Some("claude-3-5-haiku".to_string())
3194 );
3195 }
3196
3197 #[test]
3198 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
3199 let mut config = Config::default();
3200 config.provider = "openai".to_string();
3201 config.features.provider_model_ref = true;
3202 config.defaults = Some(DefaultsConfig {
3203 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3204 fast: Some(bamboo_domain::ProviderModelRef::new(
3205 "anthropic",
3206 "claude-3-5-haiku",
3207 )),
3208 vision: None,
3209 memory_background: None,
3210 planning: None,
3211 search: None,
3212 code_review: None,
3213 sub_agent: None,
3214 subagent_models: Default::default(),
3215 });
3216
3217 assert_eq!(
3218 config.get_memory_background_model(),
3219 Some("claude-3-5-haiku".to_string())
3220 );
3221 }
3222
3223 #[test]
3224 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
3225 let mut config = Config::default();
3226 config.provider = "openai".to_string();
3227 config.providers.openai = Some(OpenAIConfig {
3228 api_key: "test".to_string(),
3229 api_key_encrypted: None,
3230 base_url: None,
3231 model: Some("gpt-4o".to_string()),
3232 fast_model: Some("legacy-gpt-4o-mini".to_string()),
3233 vision_model: None,
3234 reasoning_effort: None,
3235 responses_only_models: vec![],
3236 request_overrides: None,
3237 extra: Default::default(),
3238 });
3239 config.features.provider_model_ref = false;
3240 config.defaults = Some(DefaultsConfig {
3241 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3242 fast: Some(bamboo_domain::ProviderModelRef::new(
3243 "anthropic",
3244 "claude-3-5-haiku",
3245 )),
3246 vision: None,
3247 memory_background: Some(bamboo_domain::ProviderModelRef::new(
3248 "anthropic",
3249 "claude-3-5-haiku",
3250 )),
3251 planning: None,
3252 search: None,
3253 code_review: None,
3254 sub_agent: None,
3255 subagent_models: Default::default(),
3256 });
3257
3258 assert_eq!(
3259 config.get_memory_background_model(),
3260 Some("legacy-gpt-4o-mini".to_string())
3261 );
3262 }
3263}