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)]
117pub struct MemoryConfig {
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub background_model: Option<String>,
122 #[serde(default)]
124 pub auto_dream_enabled: bool,
125 #[serde(default = "default_auto_dream_interval_secs")]
129 pub auto_dream_interval_secs: u64,
130 #[serde(
132 default = "default_true_memory_project_prompt_injection",
133 alias = "memory_project_prompt_injection"
134 )]
135 pub project_prompt_injection: bool,
136 #[serde(
138 default = "default_true_memory_relevant_recall",
139 alias = "memory_relevant_recall"
140 )]
141 pub relevant_recall: bool,
142 #[serde(default, alias = "memory_relevant_recall_rerank")]
145 pub relevant_recall_rerank: bool,
146 #[serde(
148 default = "default_true_memory_project_first_dream",
149 alias = "memory_project_first_dream"
150 )]
151 pub project_first_dream: bool,
152 #[serde(default, alias = "memory_dream_refine_mode")]
156 pub dream_refine_mode: bool,
157 #[serde(default, alias = "memory_gardener_enabled")]
160 pub gardener_enabled: bool,
161 #[serde(default = "default_gardener_interval_secs")]
163 pub gardener_interval_secs: u64,
164 #[serde(default = "default_gardener_max_splits_per_run")]
166 pub gardener_max_splits_per_run: usize,
167 #[serde(default = "default_gardener_min_sections")]
169 pub gardener_min_sections: usize,
170 #[serde(default, alias = "memory_dedup_gardener_enabled")]
173 pub dedup_gardener_enabled: bool,
174 #[serde(default = "default_dedup_gardener_min_score")]
177 pub dedup_gardener_min_score: f64,
178 #[serde(default = "default_dedup_gardener_max_merges_per_run")]
180 pub dedup_gardener_max_merges_per_run: usize,
181}
182
183impl Default for MemoryConfig {
184 fn default() -> Self {
185 Self {
186 background_model: None,
187 auto_dream_enabled: false,
188 auto_dream_interval_secs: default_auto_dream_interval_secs(),
189 project_prompt_injection: default_true_memory_project_prompt_injection(),
190 relevant_recall: default_true_memory_relevant_recall(),
191 relevant_recall_rerank: false,
192 project_first_dream: default_true_memory_project_first_dream(),
193 dream_refine_mode: false,
194 gardener_enabled: false,
195 gardener_interval_secs: default_gardener_interval_secs(),
196 gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
197 gardener_min_sections: default_gardener_min_sections(),
198 dedup_gardener_enabled: false,
199 dedup_gardener_min_score: default_dedup_gardener_min_score(),
200 dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
201 }
202 }
203}
204
205fn default_gardener_interval_secs() -> u64 {
206 86_400
207}
208
209fn default_auto_dream_interval_secs() -> u64 {
210 60 * 30
211}
212
213fn default_gardener_max_splits_per_run() -> usize {
214 8
215}
216
217fn default_gardener_min_sections() -> usize {
218 5
219}
220
221fn default_dedup_gardener_min_score() -> f64 {
222 0.6
223}
224
225fn default_dedup_gardener_max_merges_per_run() -> usize {
226 8
227}
228
229fn default_true_memory_project_prompt_injection() -> bool {
230 true
231}
232
233fn default_true_memory_relevant_recall() -> bool {
234 true
235}
236
237fn default_true_memory_project_first_dream() -> bool {
238 true
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
252pub struct SubagentsConfig {
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub max_concurrent: Option<usize>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub worker_bin: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub worker_args: Option<Vec<String>>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub fabric_dir: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub executor: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub broker: Option<BrokerClientConfig>,
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
280pub struct BrokerClientConfig {
281 pub endpoint: String,
283 #[serde(default)]
285 pub token: String,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Config {
294 #[serde(default)]
296 pub http_proxy: String,
297 #[serde(default)]
299 pub https_proxy: String,
300 #[serde(skip_serializing)]
304 pub proxy_auth: Option<ProxyAuth>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub proxy_auth_encrypted: Option<String>,
311 #[serde(default)]
313 pub headless_auth: bool,
314
315 #[serde(default = "default_provider")]
317 pub provider: String,
318
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub defaults: Option<DefaultsConfig>,
322
323 #[serde(default)]
325 pub providers: ProviderConfigs,
326
327 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
334 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
335
336 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub default_provider_instance: Option<String>,
341
342 #[serde(default)]
344 pub server: ServerConfig,
345
346 #[serde(default)]
350 pub keyword_masking: KeywordMaskingConfig,
351
352 #[serde(default)]
356 pub anthropic_model_mapping: AnthropicModelMapping,
357
358 #[serde(default)]
362 pub gemini_model_mapping: GeminiModelMapping,
363
364 #[serde(default)]
369 pub hooks: HooksConfig,
370
371 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
375 pub tools: ToolsConfig,
376
377 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
382 pub skills: SkillsConfig,
383
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
388 pub env_vars: Vec<EnvVarEntry>,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub default_work_area: Option<DefaultWorkAreaConfig>,
393
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub access_control: Option<AccessControlConfig>,
397
398 #[serde(default)]
400 pub features: FeatureFlags,
401
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub memory: Option<MemoryConfig>,
405
406 #[serde(default)]
415 pub subagents: SubagentsConfig,
416
417 #[serde(default, rename = "mcpServers", alias = "mcp")]
423 pub mcp: bamboo_domain::mcp_config::McpConfig,
424
425 #[serde(default, flatten)]
431 pub extra: BTreeMap<String, Value>,
432}
433
434#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct ProviderConfigs {
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub openai: Option<OpenAIConfig>,
442 #[serde(skip_serializing_if = "Option::is_none")]
444 pub anthropic: Option<AnthropicConfig>,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub gemini: Option<GeminiConfig>,
448 #[serde(skip_serializing_if = "Option::is_none")]
450 pub copilot: Option<CopilotConfig>,
451 #[serde(skip_serializing_if = "Option::is_none")]
453 pub bodhi: Option<BodhiConfig>,
454
455 #[serde(default, flatten)]
457 pub extra: BTreeMap<String, Value>,
458}
459
460#[derive(Debug, Clone, Default, Serialize, Deserialize)]
462pub struct FeatureFlags {
463 #[serde(default)]
465 pub provider_model_ref: bool,
466 #[serde(default)]
468 pub dynamic_model_routing: bool,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475pub struct DefaultsConfig {
476 pub chat: bamboo_domain::ProviderModelRef,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub fast: Option<bamboo_domain::ProviderModelRef>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub vision: Option<bamboo_domain::ProviderModelRef>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub planning: Option<bamboo_domain::ProviderModelRef>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub search: Option<bamboo_domain::ProviderModelRef>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub code_review: Option<bamboo_domain::ProviderModelRef>,
497 #[serde(
500 default,
501 skip_serializing_if = "Option::is_none",
502 alias = "sub_session"
503 )]
504 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
505 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
509 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
510}
511
512#[derive(Debug, Clone, Default, Serialize, Deserialize)]
514pub struct HooksConfig {
515 #[serde(default)]
517 pub image_fallback: ImageFallbackHookConfig,
518}
519
520#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
527pub struct RequestOverridesConfig {
528 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
530 pub common: RequestScopeOverride,
531 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
533 pub endpoints: BTreeMap<String, RequestScopeOverride>,
534 #[serde(default, skip_serializing_if = "Vec::is_empty")]
536 pub rules: Vec<ModelRequestRule>,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
541pub struct ModelRequestRule {
542 pub model_pattern: String,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub endpoint: Option<String>,
547 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
549 pub scope: RequestScopeOverride,
550}
551
552#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
554pub struct RequestScopeOverride {
555 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
557 pub headers: BTreeMap<String, TemplateExpr>,
558 #[serde(default, skip_serializing_if = "Vec::is_empty")]
560 pub body_patch: Vec<BodyPatch>,
561}
562
563impl RequestScopeOverride {
564 pub fn is_empty(&self) -> bool {
565 self.headers.is_empty() && self.body_patch.is_empty()
566 }
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
571pub struct BodyPatch {
572 pub path: String,
574 #[serde(default)]
576 pub op: BodyPatchOp,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub value: Option<PatchValue>,
580}
581
582#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
584#[serde(rename_all = "snake_case")]
585pub enum BodyPatchOp {
586 #[default]
587 Set,
588 Remove,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
593#[serde(untagged)]
594pub enum PatchValue {
595 Template(TemplateExpr),
596 Json(Value),
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
601#[serde(untagged)]
602pub enum TemplateExpr {
603 Literal(String),
605 Structured(TemplateExprSpec),
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
611#[serde(tag = "type", rename_all = "snake_case")]
612pub enum TemplateExprSpec {
613 Literal { value: String },
615 EnvRef {
617 name: String,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
619 fallback: Option<String>,
620 },
621 Generated { generator: GeneratedValue },
623 Format { template: String },
625}
626
627#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
629#[serde(rename_all = "snake_case")]
630pub enum GeneratedValue {
631 Uuid,
632 UnixMs,
633}
634
635#[derive(Debug, Clone, Default, Serialize, Deserialize)]
637pub struct ToolsConfig {
638 #[serde(default, skip_serializing_if = "Vec::is_empty")]
640 pub disabled: Vec<String>,
641}
642
643impl ToolsConfig {
644 fn is_empty(&self) -> bool {
645 self.disabled.is_empty()
646 }
647}
648
649#[derive(Debug, Clone, Default, Serialize, Deserialize)]
651pub struct SkillsConfig {
652 #[serde(default, skip_serializing_if = "Vec::is_empty")]
654 pub disabled: Vec<String>,
655}
656
657impl SkillsConfig {
658 fn is_empty(&self) -> bool {
659 self.disabled.is_empty()
660 }
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct ImageFallbackHookConfig {
669 #[serde(default = "default_true_hooks")]
670 pub enabled: bool,
671
672 #[serde(default = "default_image_fallback_mode")]
674 pub mode: String,
675}
676
677impl Default for ImageFallbackHookConfig {
678 fn default() -> Self {
679 Self {
680 enabled: default_true_hooks(),
681 mode: default_image_fallback_mode(),
682 }
683 }
684}
685
686fn default_image_fallback_mode() -> String {
687 "placeholder".to_string()
688}
689
690fn default_true_hooks() -> bool {
691 false
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct OpenAIConfig {
709 #[serde(default, skip_serializing)]
713 pub api_key: String,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub api_key_encrypted: Option<String>,
717 #[serde(skip_serializing_if = "Option::is_none")]
719 pub base_url: Option<String>,
720 #[serde(skip_serializing_if = "Option::is_none")]
722 pub model: Option<String>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub fast_model: Option<String>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub vision_model: Option<String>,
731 #[serde(skip_serializing_if = "Option::is_none")]
733 pub reasoning_effort: Option<ReasoningEffort>,
734
735 #[serde(default, skip_serializing_if = "Vec::is_empty")]
742 pub responses_only_models: Vec<String>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub request_overrides: Option<RequestOverridesConfig>,
746
747 #[serde(default, flatten)]
749 pub extra: BTreeMap<String, Value>,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct AnthropicConfig {
765 #[serde(default, skip_serializing)]
769 pub api_key: String,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub api_key_encrypted: Option<String>,
773 #[serde(skip_serializing_if = "Option::is_none")]
775 pub base_url: Option<String>,
776 #[serde(skip_serializing_if = "Option::is_none")]
778 pub model: Option<String>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub fast_model: Option<String>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub vision_model: Option<String>,
787 #[serde(skip_serializing_if = "Option::is_none")]
789 pub max_tokens: Option<u32>,
790 #[serde(skip_serializing_if = "Option::is_none")]
792 pub reasoning_effort: Option<ReasoningEffort>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub request_overrides: Option<RequestOverridesConfig>,
796
797 #[serde(default, flatten)]
799 pub extra: BTreeMap<String, Value>,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct GeminiConfig {
814 #[serde(default, skip_serializing)]
818 pub api_key: String,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub api_key_encrypted: Option<String>,
822 #[serde(skip_serializing_if = "Option::is_none")]
824 pub base_url: Option<String>,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 pub model: Option<String>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub fast_model: Option<String>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub vision_model: Option<String>,
836 #[serde(skip_serializing_if = "Option::is_none")]
838 pub reasoning_effort: Option<ReasoningEffort>,
839 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub request_overrides: Option<RequestOverridesConfig>,
842
843 #[serde(default, flatten)]
845 pub extra: BTreeMap<String, Value>,
846}
847
848#[derive(Debug, Clone, Default, Serialize, Deserialize)]
860pub struct CopilotConfig {
861 #[serde(default)]
863 pub enabled: bool,
864 #[serde(default)]
866 pub headless_auth: bool,
867 #[serde(skip_serializing_if = "Option::is_none")]
869 pub model: Option<String>,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub fast_model: Option<String>,
874 #[serde(default, skip_serializing_if = "Option::is_none")]
877 pub vision_model: Option<String>,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub reasoning_effort: Option<ReasoningEffort>,
881
882 #[serde(default, skip_serializing_if = "Vec::is_empty")]
891 pub responses_only_models: Vec<String>,
892 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub request_overrides: Option<RequestOverridesConfig>,
895
896 #[serde(default, flatten)]
898 pub extra: BTreeMap<String, Value>,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct BodhiConfig {
907 #[serde(default, skip_serializing)]
909 pub api_key: String,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub api_key_encrypted: Option<String>,
913 #[serde(skip_serializing_if = "Option::is_none")]
915 pub base_url: Option<String>,
916 #[serde(skip_serializing_if = "Option::is_none")]
918 pub target_provider: Option<String>,
919 #[serde(skip_serializing_if = "Option::is_none")]
921 pub reasoning_effort: Option<ReasoningEffort>,
922
923 #[serde(default, flatten)]
925 pub extra: BTreeMap<String, Value>,
926}
927
928fn default_provider() -> String {
930 "anthropic".to_string()
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
965pub struct ProviderInstanceConfig {
966 pub provider_type: String,
971
972 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub label: Option<String>,
975
976 #[serde(default, skip_serializing)]
978 pub api_key: String,
979
980 #[serde(default, skip_serializing_if = "Option::is_none")]
983 pub api_key_encrypted: Option<String>,
984
985 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub base_url: Option<String>,
988
989 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub model: Option<String>,
992
993 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub fast_model: Option<String>,
996
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub vision_model: Option<String>,
1000
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
1004
1005 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1007 pub responses_only_models: Vec<String>,
1008
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub request_overrides: Option<RequestOverridesConfig>,
1012
1013 #[serde(default = "default_true")]
1016 pub enabled: bool,
1017
1018 #[serde(default, flatten)]
1020 pub extra: BTreeMap<String, Value>,
1021}
1022
1023fn default_true() -> bool {
1024 true
1025}
1026
1027fn default_port() -> u16 {
1029 9562
1030}
1031
1032fn default_bind() -> String {
1034 "127.0.0.1".to_string()
1035}
1036
1037fn default_workers() -> usize {
1039 10
1040}
1041
1042fn default_data_dir() -> PathBuf {
1044 super::paths::bamboo_dir()
1045}
1046
1047#[derive(Debug, Clone, Serialize, Deserialize)]
1049pub struct ServerConfig {
1050 #[serde(default = "default_port")]
1052 pub port: u16,
1053
1054 #[serde(default = "default_bind")]
1056 pub bind: String,
1057
1058 pub static_dir: Option<PathBuf>,
1060
1061 #[serde(default = "default_workers")]
1063 pub workers: usize,
1064
1065 #[serde(default, flatten)]
1067 pub extra: BTreeMap<String, Value>,
1068}
1069
1070impl Default for ServerConfig {
1071 fn default() -> Self {
1072 Self {
1073 port: default_port(),
1074 bind: default_bind(),
1075 static_dir: None,
1076 workers: default_workers(),
1077 extra: BTreeMap::new(),
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1084pub struct ProxyAuth {
1085 pub username: String,
1087 pub password: String,
1089}
1090
1091fn parse_bool_env(value: &str) -> bool {
1095 matches!(
1096 value.trim().to_ascii_lowercase().as_str(),
1097 "1" | "true" | "yes" | "y" | "on"
1098 )
1099}
1100
1101fn expand_user_path(value: &str) -> PathBuf {
1102 let trimmed = value.trim();
1103 if let Some(rest) = trimmed.strip_prefix("~/") {
1104 if let Some(home) = dirs::home_dir() {
1105 return home.join(rest);
1106 }
1107 }
1108 PathBuf::from(trimmed)
1109}
1110
1111impl Default for Config {
1112 fn default() -> Self {
1113 Self::new()
1114 }
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq)]
1119pub struct PromptSafeEnvVarEntry {
1120 pub name: String,
1121 pub secret: bool,
1122 pub description: Option<String>,
1123}
1124
1125static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
1129
1130static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
1131
1132fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
1133 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
1134}
1135
1136fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
1137 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
1138}
1139
1140impl Config {
1141 pub fn new() -> Self {
1160 Self::from_data_dir(None)
1161 }
1162
1163 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
1169 let data_dir = data_dir
1171 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
1172 .unwrap_or_else(default_data_dir);
1173
1174 let config_path = data_dir.join("config.json");
1175
1176 let mut config = if config_path.exists() {
1177 if let Ok(content) = std::fs::read_to_string(&config_path) {
1178 serde_json::from_str::<Config>(&content)
1179 .map(|mut config| {
1180 config.hydrate_proxy_auth_from_encrypted();
1181 config.hydrate_provider_api_keys_from_encrypted();
1182 config.hydrate_provider_instance_api_keys_from_encrypted();
1183 config.hydrate_mcp_secrets_from_encrypted();
1184 config.hydrate_env_vars_from_encrypted();
1185 config.normalize_tool_settings();
1186 config.normalize_skill_settings();
1187 config
1188 })
1189 .unwrap_or_else(|e| {
1190 tracing::warn!("Failed to parse config.json ({}), using defaults", e);
1191 Self::create_default()
1192 })
1193 } else {
1194 Self::create_default()
1195 }
1196 } else {
1197 Self::create_default()
1198 };
1199
1200 config.hydrate_proxy_auth_from_encrypted();
1202 config.hydrate_provider_api_keys_from_encrypted();
1204 config.hydrate_provider_instance_api_keys_from_encrypted();
1206 config.hydrate_mcp_secrets_from_encrypted();
1208 config.hydrate_env_vars_from_encrypted();
1210 config.normalize_tool_settings();
1211 config.normalize_skill_settings();
1212
1213 config.extra.remove("data_dir");
1216
1217 if let Ok(port) = std::env::var("BAMBOO_PORT") {
1219 if let Ok(port) = port.parse() {
1220 config.server.port = port;
1221 }
1222 }
1223
1224 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
1225 config.server.bind = bind;
1226 }
1227
1228 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
1230 config.provider = provider;
1231 }
1232
1233 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
1234 config.headless_auth = parse_bool_env(&headless);
1235 }
1236
1237 if let Ok(project_prompt_injection) =
1238 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
1239 {
1240 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1241 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
1242 }
1243
1244 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
1245 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1246 memory.relevant_recall = parse_bool_env(&relevant_recall);
1247 }
1248
1249 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
1250 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1251 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
1252 }
1253
1254 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
1255 let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1256 memory.project_first_dream = parse_bool_env(&project_first_dream);
1257 }
1258
1259 config.publish_env_vars();
1261
1262 config
1263 }
1264
1265 pub fn get_model(&self) -> Option<String> {
1273 if self.features.provider_model_ref {
1274 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
1275 return Some(model_ref.model.clone());
1276 }
1277 }
1278 match self.provider.as_str() {
1279 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
1280 "anthropic" => self
1281 .providers
1282 .anthropic
1283 .as_ref()
1284 .and_then(|c| c.model.clone()),
1285 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
1286 "copilot" => Some(
1287 self.providers
1288 .copilot
1289 .as_ref()
1290 .and_then(|c| c.model.clone())
1291 .unwrap_or_else(|| "gpt-4o".to_string()),
1292 ),
1293 _ => None,
1294 }
1295 }
1296
1297 pub fn get_fast_model(&self) -> Option<String> {
1305 if self.features.provider_model_ref {
1306 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
1307 return Some(model_ref.model.clone());
1308 }
1309 }
1310 let fast = match self.provider.as_str() {
1311 "openai" => self
1312 .providers
1313 .openai
1314 .as_ref()
1315 .and_then(|c| c.fast_model.clone()),
1316 "anthropic" => self
1317 .providers
1318 .anthropic
1319 .as_ref()
1320 .and_then(|c| c.fast_model.clone()),
1321 "gemini" => self
1322 .providers
1323 .gemini
1324 .as_ref()
1325 .and_then(|c| c.fast_model.clone()),
1326 "copilot" => self
1327 .providers
1328 .copilot
1329 .as_ref()
1330 .and_then(|c| c.fast_model.clone()),
1331 _ => None,
1332 };
1333 fast.or_else(|| self.get_model())
1334 }
1335
1336 pub fn get_task_summary_model(&self) -> Option<String> {
1344 if self.features.provider_model_ref {
1345 if let Some(model_ref) = self
1346 .defaults
1347 .as_ref()
1348 .and_then(|d| d.task_summary.as_ref())
1349 .or_else(|| {
1350 self.defaults
1351 .as_ref()
1352 .and_then(|d| d.memory_background.as_ref())
1353 })
1354 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
1355 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
1356 {
1357 return Some(model_ref.model.clone());
1358 }
1359 }
1360
1361 self.get_memory_background_model()
1362 .or_else(|| self.get_model())
1363 }
1364
1365 pub fn get_memory_background_model(&self) -> Option<String> {
1377 if self.features.provider_model_ref {
1378 if let Some(model_ref) = self
1379 .defaults
1380 .as_ref()
1381 .and_then(|d| d.memory_background.as_ref())
1382 {
1383 return Some(model_ref.model.clone());
1384 }
1385 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
1386 return Some(model_ref.model.clone());
1387 }
1388 }
1389 let configured = self
1390 .memory
1391 .as_ref()
1392 .and_then(|memory| memory.background_model.as_ref())
1393 .map(|value| value.trim())
1394 .filter(|value| !value.is_empty())
1395 .map(ToString::to_string);
1396 configured.or_else(|| match self.provider.as_str() {
1397 "openai" => self
1398 .providers
1399 .openai
1400 .as_ref()
1401 .and_then(|c| c.fast_model.clone()),
1402 "anthropic" => self
1403 .providers
1404 .anthropic
1405 .as_ref()
1406 .and_then(|c| c.fast_model.clone()),
1407 "gemini" => self
1408 .providers
1409 .gemini
1410 .as_ref()
1411 .and_then(|c| c.fast_model.clone()),
1412 "copilot" => self
1413 .providers
1414 .copilot
1415 .as_ref()
1416 .and_then(|c| c.fast_model.clone()),
1417 _ => None,
1418 })
1419 }
1420
1421 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
1429 let raw = self
1430 .default_work_area
1431 .as_ref()
1432 .and_then(|config| config.path.as_ref())
1433 .map(|value| value.trim())
1434 .filter(|value| !value.is_empty())?;
1435
1436 let candidate = expand_user_path(raw);
1437 if candidate.is_absolute() {
1438 let canonical = std::fs::canonicalize(&candidate).ok();
1439 return canonical
1440 .as_ref()
1441 .filter(|path| path.is_dir())
1442 .map(|_| candidate.clone())
1443 .or_else(|| candidate.is_dir().then_some(candidate));
1444 }
1445
1446 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
1447 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
1448 canonical
1449 .as_ref()
1450 .filter(|path| path.is_dir())
1451 .map(|_| from_bamboo_dir.clone())
1452 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
1453 .or_else(|| candidate.is_dir().then_some(candidate))
1454 }
1455
1456 pub fn get_vision_model(&self) -> Option<String> {
1461 let vision = match self.provider.as_str() {
1462 "openai" => self
1463 .providers
1464 .openai
1465 .as_ref()
1466 .and_then(|c| c.vision_model.clone()),
1467 "anthropic" => self
1468 .providers
1469 .anthropic
1470 .as_ref()
1471 .and_then(|c| c.vision_model.clone()),
1472 "gemini" => self
1473 .providers
1474 .gemini
1475 .as_ref()
1476 .and_then(|c| c.vision_model.clone()),
1477 "copilot" => self
1478 .providers
1479 .copilot
1480 .as_ref()
1481 .and_then(|c| c.vision_model.clone()),
1482 _ => None,
1483 };
1484 vision.or_else(|| self.get_model())
1485 }
1486
1487 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
1489 self.reasoning_effort_for_key(&self.provider)
1490 }
1491
1492 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
1502 let trimmed = key.trim();
1503 if trimmed.is_empty() {
1504 return None;
1505 }
1506
1507 if let Some(instance) = self.provider_instances.get(trimmed) {
1509 return instance.reasoning_effort;
1510 }
1511
1512 match trimmed {
1514 "openai" => self
1515 .providers
1516 .openai
1517 .as_ref()
1518 .and_then(|c| c.reasoning_effort),
1519 "anthropic" => self
1520 .providers
1521 .anthropic
1522 .as_ref()
1523 .and_then(|c| c.reasoning_effort),
1524 "gemini" => self
1525 .providers
1526 .gemini
1527 .as_ref()
1528 .and_then(|c| c.reasoning_effort),
1529 "copilot" => self
1530 .providers
1531 .copilot
1532 .as_ref()
1533 .and_then(|c| c.reasoning_effort),
1534 "bodhi" => self
1535 .providers
1536 .bodhi
1537 .as_ref()
1538 .and_then(|c| c.reasoning_effort),
1539 _ => None,
1540 }
1541 }
1542
1543 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
1545 self.tools
1546 .disabled
1547 .iter()
1548 .map(|name| name.trim())
1549 .filter(|name| !name.is_empty())
1550 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
1551 .collect()
1552 }
1553
1554 pub fn normalize_tool_settings(&mut self) {
1556 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
1557 }
1558
1559 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
1561 self.skills
1562 .disabled
1563 .iter()
1564 .map(|id| id.trim())
1565 .filter(|id| !id.is_empty())
1566 .map(|id| id.to_string())
1567 .collect()
1568 }
1569
1570 pub fn normalize_skill_settings(&mut self) {
1572 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
1573 }
1574
1575 pub fn effective_default_provider(&self) -> &str {
1580 self.default_provider_instance
1581 .as_deref()
1582 .unwrap_or(&self.provider)
1583 }
1584
1585 pub fn has_provider_instances(&self) -> bool {
1587 !self.provider_instances.is_empty()
1588 }
1589
1590 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
1592 self.env_vars
1593 .iter()
1594 .filter(|e| !e.value.trim().is_empty())
1595 .map(|e| (e.name.clone(), e.value.clone()))
1596 .collect()
1597 }
1598
1599 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
1600 self.env_vars
1601 .iter()
1602 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
1603 .map(|entry| PromptSafeEnvVarEntry {
1604 name: entry.name.clone(),
1605 secret: entry.secret,
1606 description: entry
1607 .description
1608 .as_ref()
1609 .map(|value| value.trim().to_string())
1610 .filter(|value| !value.is_empty()),
1611 })
1612 .collect()
1613 }
1614
1615 pub fn publish_env_vars(&self) {
1617 let map = self.env_vars_as_map();
1618 let mut env_guard = env_vars_cache()
1619 .write()
1620 .unwrap_or_else(|poisoned| poisoned.into_inner());
1621 *env_guard = map;
1622
1623 let prompt_safe = self.prompt_safe_env_vars();
1624 let mut prompt_guard = prompt_safe_env_vars_cache()
1625 .write()
1626 .unwrap_or_else(|poisoned| poisoned.into_inner());
1627 *prompt_guard = prompt_safe;
1628 }
1629
1630 pub fn current_env_vars() -> HashMap<String, String> {
1632 env_vars_cache()
1633 .read()
1634 .unwrap_or_else(|poisoned| poisoned.into_inner())
1635 .clone()
1636 }
1637
1638 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
1640 prompt_safe_env_vars_cache()
1641 .read()
1642 .unwrap_or_else(|poisoned| poisoned.into_inner())
1643 .clone()
1644 }
1645
1646 fn create_default() -> Self {
1648 Config {
1649 http_proxy: String::new(),
1650 https_proxy: String::new(),
1651 proxy_auth: None,
1652 proxy_auth_encrypted: None,
1653 headless_auth: false,
1654 subagents: SubagentsConfig::default(),
1655 provider: default_provider(),
1656 providers: ProviderConfigs::default(),
1657 provider_instances: HashMap::new(),
1658 default_provider_instance: None,
1659 server: ServerConfig::default(),
1660 keyword_masking: KeywordMaskingConfig::default(),
1661 anthropic_model_mapping: AnthropicModelMapping::default(),
1662 gemini_model_mapping: GeminiModelMapping::default(),
1663 hooks: HooksConfig::default(),
1664 tools: ToolsConfig::default(),
1665 skills: SkillsConfig::default(),
1666 env_vars: Vec::new(),
1667 default_work_area: None,
1668 access_control: None,
1669 features: FeatureFlags::default(),
1670 defaults: None,
1671 memory: None,
1672 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1673 extra: BTreeMap::new(),
1674 }
1675 }
1676
1677 pub fn server_addr(&self) -> String {
1679 format!("{}:{}", self.server.bind, self.server.port)
1680 }
1681
1682 pub fn save(&self) -> Result<()> {
1684 self.save_to_dir(default_data_dir())
1685 }
1686
1687 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
1691 let path = data_dir.join("config.json");
1692
1693 if let Some(parent) = path.parent() {
1694 std::fs::create_dir_all(parent)
1695 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
1696 }
1697
1698 let mut to_save = self.clone();
1699 to_save.extra.remove("data_dir");
1701 to_save.extra.remove("model");
1703 to_save.refresh_proxy_auth_encrypted()?;
1704 to_save.refresh_provider_api_keys_encrypted()?;
1705 to_save.refresh_provider_instance_api_keys_encrypted()?;
1706 to_save.refresh_env_vars_encrypted()?;
1707 to_save.sanitize_env_vars_for_disk();
1708 to_save.normalize_tool_settings();
1709 to_save.normalize_skill_settings();
1710 let content =
1711 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
1712 write_atomic(&path, content.as_bytes())
1713 .with_context(|| format!("Failed to write config file: {:?}", path))?;
1714
1715 Ok(())
1716 }
1717}
1718
1719fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
1720 let Some(parent) = path.parent() else {
1721 return std::fs::write(path, content);
1722 };
1723
1724 std::fs::create_dir_all(parent)?;
1725
1726 let file_name = path
1729 .file_name()
1730 .and_then(|s| s.to_str())
1731 .unwrap_or("config.json");
1732 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1733 let tmp_path = parent.join(tmp_name);
1734
1735 {
1736 let mut file = std::fs::File::create(&tmp_path)?;
1737 file.write_all(content)?;
1738 file.sync_all()?;
1739 }
1740
1741 std::fs::rename(&tmp_path, path)?;
1742 Ok(())
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748 use std::ffi::OsString;
1749 use std::path::PathBuf;
1750 use std::sync::Mutex;
1751 use std::time::{SystemTime, UNIX_EPOCH};
1752
1753 struct EnvVarGuard {
1754 key: &'static str,
1755 previous: Option<OsString>,
1756 }
1757
1758 impl EnvVarGuard {
1759 fn set(key: &'static str, value: &str) -> Self {
1760 let previous = std::env::var_os(key);
1761 std::env::set_var(key, value);
1762 Self { key, previous }
1763 }
1764
1765 fn unset(key: &'static str) -> Self {
1766 let previous = std::env::var_os(key);
1767 std::env::remove_var(key);
1768 Self { key, previous }
1769 }
1770 }
1771
1772 impl Drop for EnvVarGuard {
1773 fn drop(&mut self) {
1774 match &self.previous {
1775 Some(value) => std::env::set_var(self.key, value),
1776 None => std::env::remove_var(self.key),
1777 }
1778 }
1779 }
1780
1781 #[test]
1782 fn reasoning_effort_for_key_resolves_instance_id() {
1783 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
1787 "provider_type": "copilot",
1788 "reasoning_effort": "high",
1789 }))
1790 .expect("instance config should deserialize");
1791
1792 let mut config = Config::create_default();
1793 config
1794 .provider_instances
1795 .insert("copilot-work".to_string(), instance);
1796
1797 assert_eq!(
1798 config.reasoning_effort_for_key("copilot-work"),
1799 Some(ReasoningEffort::High),
1800 );
1801 }
1802
1803 #[test]
1804 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
1805 let mut config = Config::create_default();
1807 config.providers.bodhi = Some(
1808 serde_json::from_value(serde_json::json!({
1809 "reasoning_effort": "xhigh",
1810 }))
1811 .expect("bodhi config should deserialize"),
1812 );
1813
1814 assert_eq!(
1815 config.reasoning_effort_for_key("bodhi"),
1816 Some(ReasoningEffort::Xhigh),
1817 );
1818 }
1819
1820 #[test]
1821 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
1822 let mut config = Config::create_default();
1823 config.providers.openai = Some(
1824 serde_json::from_value(serde_json::json!({
1825 "api_key": "sk-test",
1826 "reasoning_effort": "low",
1827 }))
1828 .expect("openai config should deserialize"),
1829 );
1830
1831 assert_eq!(
1832 config.reasoning_effort_for_key("openai"),
1833 Some(ReasoningEffort::Low),
1834 );
1835 }
1836
1837 #[test]
1838 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
1839 let config = Config::create_default();
1840 assert_eq!(config.reasoning_effort_for_key("nope"), None);
1841 assert_eq!(config.reasoning_effort_for_key(" "), None);
1842 }
1843
1844 struct TempHome {
1845 path: PathBuf,
1846 }
1847
1848 impl TempHome {
1849 fn new() -> Self {
1850 let nanos = SystemTime::now()
1851 .duration_since(UNIX_EPOCH)
1852 .expect("clock should be after unix epoch")
1853 .as_nanos();
1854 let path = std::env::temp_dir().join(format!(
1855 "chat-core-config-test-{}-{}",
1856 std::process::id(),
1857 nanos
1858 ));
1859 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1860 Self { path }
1861 }
1862
1863 fn set_config_json(&self, content: &str) {
1864 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1867 std::fs::write(self.path.join("config.json"), content)
1868 .expect("failed to write config.json");
1869 }
1870 }
1871
1872 impl Drop for TempHome {
1873 fn drop(&mut self) {
1874 let _ = std::fs::remove_dir_all(&self.path);
1875 }
1876 }
1877
1878 fn env_lock() -> &'static Mutex<()> {
1882 crate::test_support::env_cache_lock()
1883 }
1884
1885 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1887 env_lock().lock().unwrap_or_else(|poisoned| {
1888 poisoned.into_inner()
1890 })
1891 }
1892
1893 #[test]
1894 fn parse_bool_env_true_values() {
1895 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1896 assert!(parse_bool_env(value), "value {value:?} should be true");
1897 }
1898 }
1899
1900 #[test]
1901 fn parse_bool_env_false_values() {
1902 for value in ["0", "false", "no", "off", "", " "] {
1903 assert!(!parse_bool_env(value), "value {value:?} should be false");
1904 }
1905 }
1906
1907 #[test]
1908 fn config_new_ignores_http_proxy_env_vars() {
1909 let _lock = env_lock_acquire();
1910 let temp_home = TempHome::new();
1911 temp_home.set_config_json(
1912 r#"{
1913 "http_proxy": "",
1914 "https_proxy": ""
1915}"#,
1916 );
1917
1918 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1919 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1920
1921 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1922
1923 assert!(
1924 config.http_proxy.is_empty(),
1925 "config should ignore HTTP_PROXY env var"
1926 );
1927 assert!(
1928 config.https_proxy.is_empty(),
1929 "config should ignore HTTPS_PROXY env var"
1930 );
1931 }
1932
1933 #[test]
1934 fn config_new_loads_config_when_proxy_fields_omitted() {
1935 let _lock = env_lock_acquire();
1936 let temp_home = TempHome::new();
1937 temp_home.set_config_json(
1938 r#"{
1939 "provider": "openai",
1940 "providers": {
1941 "openai": {
1942 "api_key": "sk-test",
1943 "model": "gpt-4o"
1944 }
1945 }
1946}"#,
1947 );
1948
1949 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1950 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1951
1952 let config = Config::from_data_dir(Some(temp_home.path.clone()));
1953
1954 assert_eq!(
1955 config
1956 .providers
1957 .openai
1958 .as_ref()
1959 .and_then(|c| c.model.as_deref()),
1960 Some("gpt-4o"),
1961 "config should load provider model from config file even when proxy fields are omitted"
1962 );
1963 assert!(config.http_proxy.is_empty());
1964 assert!(config.https_proxy.is_empty());
1965 }
1966
1967 #[test]
1968 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
1969 let _lock = crate::test_support::env_cache_lock_acquire();
1970 let config = Config {
1971 env_vars: vec![
1972 EnvVarEntry {
1973 name: "SECRET_TOKEN".to_string(),
1974 value: "top-secret".to_string(),
1975 secret: true,
1976 value_encrypted: None,
1977 description: Some("Service token".to_string()),
1978 },
1979 EnvVarEntry {
1980 name: "API_BASE".to_string(),
1981 value: "https://internal.example".to_string(),
1982 secret: false,
1983 value_encrypted: None,
1984 description: Some("Internal API base".to_string()),
1985 },
1986 ],
1987 ..Default::default()
1988 };
1989
1990 config.publish_env_vars();
1991
1992 let injected = Config::current_env_vars();
1993 assert_eq!(
1994 injected.get("SECRET_TOKEN").map(String::as_str),
1995 Some("top-secret")
1996 );
1997 assert_eq!(
1998 injected.get("API_BASE").map(String::as_str),
1999 Some("https://internal.example")
2000 );
2001
2002 let prompt_safe = Config::current_prompt_safe_env_vars();
2003 assert_eq!(prompt_safe.len(), 2);
2004 assert!(prompt_safe.iter().any(|entry| {
2005 entry.name == "SECRET_TOKEN"
2006 && entry.secret
2007 && entry.description.as_deref() == Some("Service token")
2008 }));
2009 assert!(prompt_safe.iter().any(|entry| {
2010 entry.name == "API_BASE"
2011 && !entry.secret
2012 && entry.description.as_deref() == Some("Internal API base")
2013 }));
2014 assert!(!prompt_safe
2015 .iter()
2016 .any(|entry| entry.name.contains("top-secret")));
2017 assert!(!prompt_safe.iter().any(|entry| {
2018 entry
2019 .description
2020 .as_deref()
2021 .is_some_and(|value| value.contains("https://internal.example"))
2022 }));
2023 }
2024
2025 #[test]
2026 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
2027 let _lock = env_lock_acquire();
2028 let temp_home = TempHome::new();
2029 temp_home.set_config_json(
2030 r#"{
2031 "provider": "openai",
2032 "providers": {
2033 "openai": {
2034 "api_key": "sk-test",
2035 "model": "gpt-4o"
2036 }
2037 }
2038}"#,
2039 );
2040
2041 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
2042 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
2043
2044 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2045
2046 assert_eq!(
2047 config
2048 .providers
2049 .openai
2050 .as_ref()
2051 .and_then(|c| c.model.as_deref()),
2052 Some("gpt-4o")
2053 );
2054 assert!(
2055 config.http_proxy.is_empty(),
2056 "config should keep http_proxy empty when field is omitted"
2057 );
2058 assert!(
2059 config.https_proxy.is_empty(),
2060 "config should keep https_proxy empty when field is omitted"
2061 );
2062 }
2063
2064 #[test]
2065 fn get_memory_background_model_prefers_memory_specific_override() {
2066 let mut config = Config::default();
2067 config.features.provider_model_ref = false;
2068 config.provider = "openai".to_string();
2069 config.providers.openai = Some(OpenAIConfig {
2070 api_key: "test".to_string(),
2071 api_key_encrypted: None,
2072 base_url: None,
2073 model: Some("gpt-main".to_string()),
2074 fast_model: Some("gpt-fast".to_string()),
2075 vision_model: None,
2076 reasoning_effort: None,
2077 responses_only_models: vec![],
2078 request_overrides: None,
2079 extra: BTreeMap::new(),
2080 });
2081 config.memory = Some(MemoryConfig {
2082 background_model: Some("memory-fast".to_string()),
2083 ..MemoryConfig::default()
2084 });
2085
2086 assert_eq!(
2087 config.get_memory_background_model().as_deref(),
2088 Some("memory-fast")
2089 );
2090 }
2091
2092 #[test]
2093 fn get_memory_background_model_falls_back_to_provider_fast_model() {
2094 let mut config = Config::default();
2095 config.features.provider_model_ref = false;
2096 config.provider = "openai".to_string();
2097 config.providers.openai = Some(OpenAIConfig {
2098 api_key: "test".to_string(),
2099 api_key_encrypted: None,
2100 base_url: None,
2101 model: Some("gpt-main".to_string()),
2102 fast_model: Some("gpt-fast".to_string()),
2103 vision_model: None,
2104 reasoning_effort: None,
2105 responses_only_models: vec![],
2106 request_overrides: None,
2107 extra: BTreeMap::new(),
2108 });
2109
2110 assert_eq!(
2111 config.get_memory_background_model().as_deref(),
2112 Some("gpt-fast")
2113 );
2114 }
2115
2116 #[test]
2117 fn get_memory_background_model_does_not_fall_back_to_main_model() {
2118 let mut config = Config::default();
2119 config.features.provider_model_ref = false;
2120 config.provider = "openai".to_string();
2121 config.providers.openai = Some(OpenAIConfig {
2122 api_key: "test".to_string(),
2123 api_key_encrypted: None,
2124 base_url: None,
2125 model: Some("gpt-main".to_string()),
2126 fast_model: None,
2127 vision_model: None,
2128 reasoning_effort: None,
2129 responses_only_models: vec![],
2130 request_overrides: None,
2131 extra: BTreeMap::new(),
2132 });
2133
2134 assert!(config.get_memory_background_model().is_none());
2135 }
2136
2137 #[test]
2138 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
2139 let config = Config {
2140 memory: Some(MemoryConfig {
2141 background_model: Some("dream-fast".to_string()),
2142 auto_dream_enabled: true,
2143 auto_dream_interval_secs: 900,
2144 project_prompt_injection: false,
2145 relevant_recall: false,
2146 relevant_recall_rerank: true,
2147 project_first_dream: false,
2148 dream_refine_mode: true,
2149 gardener_enabled: true,
2150 gardener_interval_secs: 3_600,
2151 gardener_max_splits_per_run: 4,
2152 gardener_min_sections: 7,
2153 dedup_gardener_enabled: true,
2154 dedup_gardener_min_score: 0.7,
2155 dedup_gardener_max_merges_per_run: 3,
2156 }),
2157 ..Config::default()
2158 };
2159
2160 let serialized = serde_json::to_string(&config).expect("config should serialize");
2161 let roundtrip: Config =
2162 serde_json::from_str(&serialized).expect("config should deserialize");
2163 let memory = roundtrip.memory.expect("memory config should exist");
2164 assert!(memory.auto_dream_enabled);
2165 assert!(!memory.project_prompt_injection);
2166 assert!(!memory.relevant_recall);
2167 assert!(memory.relevant_recall_rerank);
2168 assert!(!memory.project_first_dream);
2169 assert!(memory.dream_refine_mode);
2170 assert!(memory.gardener_enabled);
2171 assert_eq!(memory.gardener_interval_secs, 3_600);
2172 assert_eq!(memory.gardener_max_splits_per_run, 4);
2173 assert_eq!(memory.gardener_min_sections, 7);
2174 assert!(memory.dedup_gardener_enabled);
2175 assert_eq!(memory.dedup_gardener_min_score, 0.7);
2176 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
2177 }
2178
2179 #[test]
2180 fn memory_config_env_overrides_prompt_flags() {
2181 let _lock = env_lock_acquire();
2182 let temp_home = TempHome::new();
2183 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2184 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
2185 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
2186 let _relevant_recall_rerank =
2187 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
2188 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
2189
2190 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2191 let memory = config
2192 .memory
2193 .expect("memory config should be created by env overrides");
2194 assert!(!memory.project_prompt_injection);
2195 assert!(!memory.relevant_recall);
2196 assert!(memory.relevant_recall_rerank);
2197 assert!(!memory.project_first_dream);
2198 }
2199
2200 #[test]
2201 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
2202 let _lock = env_lock_acquire();
2203 let temp_home = TempHome::new();
2204 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2205 let target = temp_home.path.join("workspace-default");
2206 std::fs::create_dir_all(&target).expect("default work area dir should exist");
2207
2208 let config = Config {
2209 default_work_area: Some(DefaultWorkAreaConfig {
2210 path: Some("~/workspace-default".to_string()),
2211 }),
2212 ..Default::default()
2213 };
2214
2215 assert_eq!(config.get_default_work_area_path(), Some(target));
2216 }
2217
2218 #[test]
2219 fn get_default_work_area_path_returns_none_for_missing_directory() {
2220 let _lock = env_lock_acquire();
2221 let temp_home = TempHome::new();
2222 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2223
2224 let config = Config {
2225 default_work_area: Some(DefaultWorkAreaConfig {
2226 path: Some("~/missing-default-work-area".to_string()),
2227 }),
2228 ..Default::default()
2229 };
2230
2231 assert!(config.get_default_work_area_path().is_none());
2232 }
2233
2234 #[test]
2235 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
2236 let mut config = Config::default();
2237 config.tools.disabled = vec![
2238 " read_file ".to_string(),
2239 "".to_string(),
2240 "read_file".to_string(),
2241 "bash".to_string(),
2242 "default::getCurrentDir".to_string(),
2243 ];
2244
2245 config.normalize_tool_settings();
2246
2247 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
2248 }
2249
2250 #[test]
2251 fn config_load_reads_disabled_tools_as_canonical_names() {
2252 let _lock = env_lock_acquire();
2253 let temp_home = TempHome::new();
2254 temp_home.set_config_json(
2255 r#"{
2256 "tools": {
2257 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
2258 }
2259}"#,
2260 );
2261
2262 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2263 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
2264 assert!(config.disabled_tool_names().contains("Bash"));
2265 assert!(config.disabled_tool_names().contains("Read"));
2266 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
2267 }
2268
2269 #[test]
2270 fn normalize_skill_settings_trims_dedupes_and_sorts() {
2271 let mut config = Config::default();
2272 config.skills.disabled = vec![
2273 " pdf ".to_string(),
2274 "".to_string(),
2275 "pdf".to_string(),
2276 "skill-creator".to_string(),
2277 ];
2278
2279 config.normalize_skill_settings();
2280
2281 assert_eq!(
2282 config.skills.disabled,
2283 vec!["pdf".to_string(), "skill-creator".to_string()]
2284 );
2285 }
2286
2287 #[test]
2288 fn config_load_reads_disabled_skills_as_normalized_ids() {
2289 let _lock = env_lock_acquire();
2290 let temp_home = TempHome::new();
2291 temp_home.set_config_json(
2292 r#"{
2293 "skills": {
2294 "disabled": [" pdf ", "skill-creator", "pdf", ""]
2295 }
2296}"#,
2297 );
2298
2299 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2300 assert_eq!(
2301 config.skills.disabled,
2302 vec!["pdf".to_string(), "skill-creator".to_string()]
2303 );
2304 assert!(config.disabled_skill_ids().contains("pdf"));
2305 assert!(config.disabled_skill_ids().contains("skill-creator"));
2306 }
2307
2308 #[test]
2309 fn test_server_config_defaults() {
2310 let _lock = env_lock_acquire();
2311 let temp_home = TempHome::new();
2312
2313 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2314 assert_eq!(config.server.port, 9562);
2315 assert_eq!(config.server.bind, "127.0.0.1");
2316 assert_eq!(config.server.workers, 10);
2317 assert!(config.server.static_dir.is_none());
2318 }
2319
2320 #[test]
2321 fn test_server_addr() {
2322 let mut config = Config::default();
2323 config.server.port = 9000;
2324 config.server.bind = "0.0.0.0".to_string();
2325 assert_eq!(config.server_addr(), "0.0.0.0:9000");
2326 }
2327
2328 #[test]
2329 fn test_env_var_overrides() {
2330 let _lock = env_lock_acquire();
2331 let temp_home = TempHome::new();
2332
2333 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
2334 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
2335 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
2336
2337 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2338 assert_eq!(config.server.port, 9999);
2339 assert_eq!(config.server.bind, "192.168.1.1");
2340 assert_eq!(config.provider, "openai");
2341 }
2342
2343 #[test]
2344 fn test_config_save_and_load() {
2345 let _lock = env_lock_acquire();
2346 let temp_home = TempHome::new();
2347
2348 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2349 config.server.port = 9000;
2350 config.server.bind = "0.0.0.0".to_string();
2351 config.provider = "anthropic".to_string();
2352
2353 config
2355 .save_to_dir(temp_home.path.clone())
2356 .expect("Failed to save config");
2357
2358 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2360
2361 assert_eq!(loaded.server.port, 9000);
2363 assert_eq!(loaded.server.bind, "0.0.0.0");
2364 assert_eq!(loaded.provider, "anthropic");
2365 }
2366
2367 #[test]
2368 fn config_decrypts_proxy_auth_from_encrypted_field() {
2369 let _lock = env_lock_acquire();
2370 let temp_home = TempHome::new();
2371
2372 let key_guard = crate::encryption::set_test_encryption_key([
2374 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2375 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2376 0x1c, 0x1d, 0x1e, 0x1f,
2377 ]);
2378
2379 let auth = ProxyAuth {
2380 username: "user".to_string(),
2381 password: "pass".to_string(),
2382 };
2383 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
2384 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
2385
2386 temp_home.set_config_json(&format!(
2387 r#"{{
2388 "http_proxy": "http://proxy.example.com:8080",
2389 "proxy_auth_encrypted": "{encrypted}"
2390}}"#
2391 ));
2392 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2393 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
2394 assert_eq!(loaded_auth.username, "user");
2395 assert_eq!(loaded_auth.password, "pass");
2396 drop(key_guard);
2397 }
2398
2399 #[test]
2400 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
2401 let _lock = env_lock_acquire();
2402 let temp_home = TempHome::new();
2403
2404 let key_guard = crate::encryption::set_test_encryption_key([
2406 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2407 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2408 0x1c, 0x1d, 0x1e, 0x1f,
2409 ]);
2410
2411 let auth = ProxyAuth {
2412 username: "user".to_string(),
2413 password: "pass".to_string(),
2414 };
2415 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
2416 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
2417
2418 temp_home.set_config_json(&format!(
2420 r#"{{
2421 "http_proxy": "http://proxy.example.com:8080",
2422 "http_proxy_auth_encrypted": "{encrypted}",
2423 "https_proxy_auth_encrypted": "{encrypted}"
2424}}"#
2425 ));
2426
2427 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2428 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
2429 assert_eq!(loaded_auth.username, "user");
2430 assert_eq!(loaded_auth.password, "pass");
2431 drop(key_guard);
2432 }
2433
2434 #[test]
2435 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
2436 let _lock = env_lock_acquire();
2437 let temp_home = TempHome::new();
2438
2439 let key_guard = crate::encryption::set_test_encryption_key([
2441 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2442 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2443 0x1c, 0x1d, 0x1e, 0x1f,
2444 ]);
2445
2446 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2447 config.proxy_auth = Some(ProxyAuth {
2448 username: "user".to_string(),
2449 password: "pass".to_string(),
2450 });
2451 config
2452 .save_to_dir(temp_home.path.clone())
2453 .expect("save should encrypt proxy auth");
2454
2455 let content =
2456 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2457 assert!(
2458 content.contains("proxy_auth_encrypted"),
2459 "config.json should store encrypted proxy auth"
2460 );
2461 assert!(
2462 !content.contains("\"proxy_auth\""),
2463 "config.json should not store plaintext proxy_auth"
2464 );
2465
2466 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2467 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
2468 assert_eq!(loaded_auth.username, "user");
2469 assert_eq!(loaded_auth.password, "pass");
2470 drop(key_guard);
2471 }
2472
2473 #[test]
2474 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
2475 let _lock = env_lock_acquire();
2476 let temp_home = TempHome::new();
2477
2478 let key_guard = crate::encryption::set_test_encryption_key([
2480 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2481 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2482 0x1c, 0x1d, 0x1e, 0x1f,
2483 ]);
2484
2485 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2486 config.provider = "openai".to_string();
2487 config.providers.openai = Some(OpenAIConfig {
2488 api_key: "sk-test-provider-key".to_string(),
2489 api_key_encrypted: None,
2490 base_url: None,
2491 model: None,
2492 fast_model: None,
2493 vision_model: None,
2494 reasoning_effort: None,
2495 responses_only_models: vec![],
2496 request_overrides: None,
2497 extra: Default::default(),
2498 });
2499
2500 config
2501 .save_to_dir(temp_home.path.clone())
2502 .expect("save should encrypt provider api keys");
2503
2504 let content =
2505 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2506 assert!(
2507 content.contains("\"api_key_encrypted\""),
2508 "config.json should store encrypted provider keys"
2509 );
2510 assert!(
2511 !content.contains("\"api_key\""),
2512 "config.json should not store plaintext provider keys"
2513 );
2514
2515 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2516 let openai = loaded
2517 .providers
2518 .openai
2519 .expect("openai config should be present");
2520 assert_eq!(openai.api_key, "sk-test-provider-key");
2521
2522 drop(key_guard);
2523 }
2524
2525 #[test]
2526 fn config_save_persists_mcp_servers_in_mainstream_format() {
2527 let _lock = env_lock_acquire();
2528 let temp_home = TempHome::new();
2529
2530 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2531
2532 let mut env = std::collections::HashMap::new();
2533 env.insert("TOKEN".to_string(), "supersecret".to_string());
2534
2535 config.mcp.servers = vec![
2536 bamboo_domain::mcp_config::McpServerConfig {
2537 id: "stdio-secret".to_string(),
2538 name: None,
2539 enabled: true,
2540 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
2541 bamboo_domain::mcp_config::StdioConfig {
2542 command: "echo".to_string(),
2543 args: vec![],
2544 cwd: None,
2545 env,
2546 env_encrypted: std::collections::HashMap::new(),
2547 startup_timeout_ms: 5000,
2548 },
2549 ),
2550 request_timeout_ms: 5000,
2551 healthcheck_interval_ms: 1000,
2552 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
2553 allowed_tools: vec![],
2554 denied_tools: vec![],
2555 },
2556 bamboo_domain::mcp_config::McpServerConfig {
2557 id: "sse-secret".to_string(),
2558 name: None,
2559 enabled: true,
2560 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
2561 bamboo_domain::mcp_config::SseConfig {
2562 url: "http://localhost:8080/sse".to_string(),
2563 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
2564 name: "Authorization".to_string(),
2565 value: "Bearer token123".to_string(),
2566 value_encrypted: None,
2567 }],
2568 connect_timeout_ms: 5000,
2569 },
2570 ),
2571 request_timeout_ms: 5000,
2572 healthcheck_interval_ms: 1000,
2573 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
2574 allowed_tools: vec![],
2575 denied_tools: vec![],
2576 },
2577 ];
2578
2579 config
2580 .save_to_dir(temp_home.path.clone())
2581 .expect("save should persist MCP servers");
2582
2583 let content =
2584 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2585 assert!(
2586 content.contains("\"mcpServers\""),
2587 "config.json should store MCP servers under the mainstream 'mcpServers' key"
2588 );
2589 assert!(
2590 content.contains("supersecret"),
2591 "config.json should persist MCP stdio env in mainstream format"
2592 );
2593 assert!(
2594 content.contains("Bearer token123"),
2595 "config.json should persist MCP SSE headers in mainstream format"
2596 );
2597 assert!(
2598 !content.contains("\"env_encrypted\""),
2599 "config.json should not persist legacy env_encrypted fields"
2600 );
2601 assert!(
2602 !content.contains("\"value_encrypted\""),
2603 "config.json should not persist legacy value_encrypted fields"
2604 );
2605
2606 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2607 let stdio = loaded
2608 .mcp
2609 .servers
2610 .iter()
2611 .find(|s| s.id == "stdio-secret")
2612 .expect("stdio server should exist");
2613 match &stdio.transport {
2614 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
2615 assert_eq!(
2616 stdio.env.get("TOKEN").map(|s| s.as_str()),
2617 Some("supersecret")
2618 );
2619 }
2620 _ => panic!("Expected stdio transport"),
2621 }
2622
2623 let sse = loaded
2624 .mcp
2625 .servers
2626 .iter()
2627 .find(|s| s.id == "sse-secret")
2628 .expect("sse server should exist");
2629 match &sse.transport {
2630 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
2631 assert_eq!(sse.headers[0].value, "Bearer token123");
2632 }
2633 _ => panic!("Expected SSE transport"),
2634 }
2635 }
2636
2637 #[test]
2640 fn env_vars_as_map_includes_only_non_empty_values() {
2641 let config = Config {
2642 env_vars: vec![
2643 EnvVarEntry {
2644 name: "A".to_string(),
2645 value: "val_a".to_string(),
2646 secret: false,
2647 value_encrypted: None,
2648 description: None,
2649 },
2650 EnvVarEntry {
2651 name: "B".to_string(),
2652 value: "".to_string(), secret: true,
2654 value_encrypted: None,
2655 description: None,
2656 },
2657 EnvVarEntry {
2658 name: "C".to_string(),
2659 value: " ".to_string(), secret: false,
2661 value_encrypted: None,
2662 description: None,
2663 },
2664 EnvVarEntry {
2665 name: "D".to_string(),
2666 value: "val_d".to_string(),
2667 secret: true,
2668 value_encrypted: Some("enc".to_string()),
2669 description: Some("desc".to_string()),
2670 },
2671 ],
2672 ..Default::default()
2673 };
2674
2675 let map = config.env_vars_as_map();
2676 assert_eq!(map.len(), 2);
2677 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
2678 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
2679 assert!(!map.contains_key("B"));
2680 assert!(!map.contains_key("C"));
2681 }
2682
2683 #[test]
2684 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
2685 let mut config = Config {
2686 env_vars: vec![
2687 EnvVarEntry {
2688 name: "PLAIN".to_string(),
2689 value: "visible".to_string(),
2690 secret: false,
2691 value_encrypted: None,
2692 description: None,
2693 },
2694 EnvVarEntry {
2695 name: "SECRET".to_string(),
2696 value: "hidden_value".to_string(),
2697 secret: true,
2698 value_encrypted: Some("enc_data".to_string()),
2699 description: None,
2700 },
2701 ],
2702 ..Default::default()
2703 };
2704
2705 config.sanitize_env_vars_for_disk();
2706
2707 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
2710
2711 #[test]
2712 fn sanitize_env_vars_for_disk_preserves_encrypted() {
2713 let mut config = Config {
2714 env_vars: vec![
2715 EnvVarEntry {
2716 name: "OPEN".to_string(),
2717 value: "val".to_string(),
2718 secret: false,
2719 value_encrypted: None,
2720 description: None,
2721 },
2722 EnvVarEntry {
2723 name: "HIDDEN".to_string(),
2724 value: "real_secret".to_string(),
2725 secret: true,
2726 value_encrypted: Some("enc".to_string()),
2727 description: None,
2728 },
2729 ],
2730 ..Default::default()
2731 };
2732
2733 config.sanitize_env_vars_for_disk();
2734
2735 assert_eq!(config.env_vars[0].value, "val");
2737 assert_eq!(config.env_vars[1].value, "");
2739 assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
2740 }
2741
2742 #[test]
2743 fn refresh_env_vars_encrypted_round_trip() {
2744 let mut config = Config {
2745 env_vars: vec![
2746 EnvVarEntry {
2747 name: "TOKEN".to_string(),
2748 value: "my-secret-token".to_string(),
2749 secret: true,
2750 value_encrypted: None,
2751 description: Some("A token".to_string()),
2752 },
2753 EnvVarEntry {
2754 name: "PLAIN_VAR".to_string(),
2755 value: "hello".to_string(),
2756 secret: false,
2757 value_encrypted: None,
2758 description: None,
2759 },
2760 ],
2761 ..Default::default()
2762 };
2763
2764 config
2766 .refresh_env_vars_encrypted()
2767 .expect("encryption should succeed");
2768
2769 assert!(config.env_vars[0].value_encrypted.is_some());
2771 assert!(config.env_vars[1].value_encrypted.is_none());
2773
2774 let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
2776 assert_ne!(encrypted, "my-secret-token"); config.sanitize_env_vars_for_disk();
2780 assert_eq!(config.env_vars[0].value, "");
2781
2782 config.hydrate_env_vars_from_encrypted();
2784 assert_eq!(config.env_vars[0].value, "my-secret-token");
2785 assert_eq!(config.env_vars[1].value, "hello"); }
2787
2788 #[test]
2789 fn publish_and_current_env_vars_round_trip() {
2790 let config = Config {
2791 env_vars: vec![EnvVarEntry {
2792 name: "TEST_PUBLISH".to_string(),
2793 value: "pub_value".to_string(),
2794 secret: false,
2795 value_encrypted: None,
2796 description: None,
2797 }],
2798 ..Default::default()
2799 };
2800
2801 for _ in 0..10 {
2802 config.publish_env_vars();
2803 let map = Config::current_env_vars();
2804 if map.get("TEST_PUBLISH") == Some(&"pub_value".to_string()) {
2805 return;
2806 }
2807 }
2808 panic!("TEST_PUBLISH not found in cache after retries");
2809 }
2810
2811 #[test]
2812 fn hydrate_skips_non_secret_entries() {
2813 let mut config = Config {
2814 env_vars: vec![EnvVarEntry {
2815 name: "PLAIN".to_string(),
2816 value: "original".to_string(),
2817 secret: false,
2818 value_encrypted: Some("should-be-ignored".to_string()),
2819 description: None,
2820 }],
2821 ..Default::default()
2822 };
2823
2824 config.hydrate_env_vars_from_encrypted();
2825 assert_eq!(config.env_vars[0].value, "original");
2827 }
2828
2829 #[test]
2830 fn default_config_has_empty_env_vars() {
2831 let _lock = env_lock_acquire();
2835 let temp_home = TempHome::new();
2836 let config = Config::from_data_dir(Some(temp_home.path.clone()));
2837 assert!(config.env_vars.is_empty());
2838 }
2839
2840 #[test]
2841 fn serde_round_trip_with_env_vars() {
2842 let config = Config {
2843 env_vars: vec![
2844 EnvVarEntry {
2845 name: "KEY1".to_string(),
2846 value: "val1".to_string(),
2847 secret: false,
2848 value_encrypted: None,
2849 description: Some("First key".to_string()),
2850 },
2851 EnvVarEntry {
2852 name: "KEY2".to_string(),
2853 value: "".to_string(), secret: true,
2855 value_encrypted: Some("enc123".to_string()),
2856 description: None,
2857 },
2858 ],
2859 ..Default::default()
2860 };
2861
2862 let json = serde_json::to_string(&config).unwrap();
2863 let restored: Config = serde_json::from_str(&json).unwrap();
2864
2865 assert_eq!(restored.env_vars.len(), 2);
2866 assert_eq!(restored.env_vars[0].name, "KEY1");
2867 assert_eq!(restored.env_vars[0].value, "val1");
2868 assert!(!restored.env_vars[0].secret);
2869 assert_eq!(restored.env_vars[1].name, "KEY2");
2870 assert!(restored.env_vars[1].secret);
2871 assert_eq!(
2872 restored.env_vars[1].value_encrypted.as_deref(),
2873 Some("enc123")
2874 );
2875 }
2876
2877 #[test]
2880 #[allow(clippy::field_reassign_with_default)]
2882 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
2883 let mut config = Config::default();
2884 config.provider = "openai".to_string();
2885 config.providers.openai = Some(OpenAIConfig {
2886 api_key: "test".to_string(),
2887 api_key_encrypted: None,
2888 base_url: None,
2889 model: Some("legacy-gpt-4o".to_string()),
2890 fast_model: None,
2891 vision_model: None,
2892 reasoning_effort: None,
2893 responses_only_models: vec![],
2894 request_overrides: None,
2895 extra: Default::default(),
2896 });
2897 config.features.provider_model_ref = true;
2898 config.defaults = Some(DefaultsConfig {
2899 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
2900 fast: None,
2901 task_summary: None,
2902 vision: None,
2903 memory_background: None,
2904 planning: None,
2905 search: None,
2906 code_review: None,
2907 sub_agent: None,
2908 subagent_models: Default::default(),
2909 });
2910
2911 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
2912 }
2913
2914 #[test]
2915 #[allow(clippy::field_reassign_with_default)]
2917 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
2918 let mut config = Config::default();
2919 config.provider = "openai".to_string();
2920 config.providers.openai = Some(OpenAIConfig {
2921 api_key: "test".to_string(),
2922 api_key_encrypted: None,
2923 base_url: None,
2924 model: Some("legacy-gpt-4o".to_string()),
2925 fast_model: None,
2926 vision_model: None,
2927 reasoning_effort: None,
2928 responses_only_models: vec![],
2929 request_overrides: None,
2930 extra: Default::default(),
2931 });
2932 config.features.provider_model_ref = false;
2933 config.defaults = Some(DefaultsConfig {
2934 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
2935 fast: None,
2936 task_summary: None,
2937 vision: None,
2938 memory_background: None,
2939 planning: None,
2940 search: None,
2941 code_review: None,
2942 sub_agent: None,
2943 subagent_models: Default::default(),
2944 });
2945
2946 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
2947 }
2948
2949 #[test]
2950 #[allow(clippy::field_reassign_with_default)]
2952 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
2953 let mut config = Config::default();
2954 config.provider = "openai".to_string();
2955 config.providers.openai = Some(OpenAIConfig {
2956 api_key: "test".to_string(),
2957 api_key_encrypted: None,
2958 base_url: None,
2959 model: Some("gpt-4o".to_string()),
2960 fast_model: Some("legacy-gpt-4o-mini".to_string()),
2961 vision_model: None,
2962 reasoning_effort: None,
2963 responses_only_models: vec![],
2964 request_overrides: None,
2965 extra: Default::default(),
2966 });
2967 config.features.provider_model_ref = true;
2968 config.defaults = Some(DefaultsConfig {
2969 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
2970 fast: Some(bamboo_domain::ProviderModelRef::new(
2971 "anthropic",
2972 "claude-3-5-haiku",
2973 )),
2974 task_summary: None,
2975 vision: None,
2976 memory_background: None,
2977 planning: None,
2978 search: None,
2979 code_review: None,
2980 sub_agent: None,
2981 subagent_models: Default::default(),
2982 });
2983
2984 assert_eq!(
2985 config.get_fast_model(),
2986 Some("claude-3-5-haiku".to_string())
2987 );
2988 }
2989
2990 #[test]
2991 #[allow(clippy::field_reassign_with_default)]
2993 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
2994 let mut config = Config::default();
2995 config.provider = "openai".to_string();
2996 config.providers.openai = Some(OpenAIConfig {
2997 api_key: "test".to_string(),
2998 api_key_encrypted: None,
2999 base_url: None,
3000 model: Some("gpt-4o".to_string()),
3001 fast_model: Some("legacy-gpt-4o-mini".to_string()),
3002 vision_model: None,
3003 reasoning_effort: None,
3004 responses_only_models: vec![],
3005 request_overrides: None,
3006 extra: Default::default(),
3007 });
3008 config.features.provider_model_ref = false;
3009 config.defaults = Some(DefaultsConfig {
3010 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3011 fast: Some(bamboo_domain::ProviderModelRef::new(
3012 "anthropic",
3013 "claude-3-5-haiku",
3014 )),
3015 task_summary: None,
3016 vision: None,
3017 memory_background: None,
3018 planning: None,
3019 search: None,
3020 code_review: None,
3021 sub_agent: None,
3022 subagent_models: Default::default(),
3023 });
3024
3025 assert_eq!(
3026 config.get_fast_model(),
3027 Some("legacy-gpt-4o-mini".to_string())
3028 );
3029 }
3030
3031 #[test]
3032 #[allow(clippy::field_reassign_with_default)]
3034 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
3035 let mut config = Config::default();
3036 config.provider = "openai".to_string();
3037 config.features.provider_model_ref = true;
3038 config.defaults = Some(DefaultsConfig {
3039 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3040 fast: None,
3041 task_summary: None,
3042 vision: None,
3043 memory_background: None,
3044 planning: None,
3045 search: None,
3046 code_review: None,
3047 sub_agent: None,
3048 subagent_models: Default::default(),
3049 });
3050
3051 assert_eq!(
3052 config.get_fast_model(),
3053 Some("claude-3-7-sonnet".to_string())
3054 );
3055 }
3056
3057 #[test]
3058 #[allow(clippy::field_reassign_with_default)]
3060 fn get_memory_background_model_prefers_defaults_memory_background() {
3061 let mut config = Config::default();
3062 config.provider = "openai".to_string();
3063 config.providers.openai = Some(OpenAIConfig {
3064 api_key: "test".to_string(),
3065 api_key_encrypted: None,
3066 base_url: None,
3067 model: Some("gpt-4o".to_string()),
3068 fast_model: Some("gpt-4o-mini".to_string()),
3069 vision_model: None,
3070 reasoning_effort: None,
3071 responses_only_models: vec![],
3072 request_overrides: None,
3073 extra: Default::default(),
3074 });
3075 config.features.provider_model_ref = true;
3076 config.defaults = Some(DefaultsConfig {
3077 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3078 fast: Some(bamboo_domain::ProviderModelRef::new(
3079 "openai",
3080 "gpt-4o-mini",
3081 )),
3082 task_summary: None,
3083 vision: None,
3084 memory_background: Some(bamboo_domain::ProviderModelRef::new(
3085 "anthropic",
3086 "claude-3-5-haiku",
3087 )),
3088 planning: None,
3089 search: None,
3090 code_review: None,
3091 sub_agent: None,
3092 subagent_models: Default::default(),
3093 });
3094
3095 assert_eq!(
3096 config.get_memory_background_model(),
3097 Some("claude-3-5-haiku".to_string())
3098 );
3099 }
3100
3101 #[test]
3102 #[allow(clippy::field_reassign_with_default)]
3104 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
3105 let mut config = Config::default();
3106 config.provider = "openai".to_string();
3107 config.features.provider_model_ref = true;
3108 config.defaults = Some(DefaultsConfig {
3109 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3110 fast: Some(bamboo_domain::ProviderModelRef::new(
3111 "anthropic",
3112 "claude-3-5-haiku",
3113 )),
3114 task_summary: None,
3115 vision: None,
3116 memory_background: None,
3117 planning: None,
3118 search: None,
3119 code_review: None,
3120 sub_agent: None,
3121 subagent_models: Default::default(),
3122 });
3123
3124 assert_eq!(
3125 config.get_memory_background_model(),
3126 Some("claude-3-5-haiku".to_string())
3127 );
3128 }
3129
3130 #[test]
3131 #[allow(clippy::field_reassign_with_default)]
3133 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
3134 let mut config = Config::default();
3135 config.provider = "openai".to_string();
3136 config.providers.openai = Some(OpenAIConfig {
3137 api_key: "test".to_string(),
3138 api_key_encrypted: None,
3139 base_url: None,
3140 model: Some("gpt-4o".to_string()),
3141 fast_model: Some("legacy-gpt-4o-mini".to_string()),
3142 vision_model: None,
3143 reasoning_effort: None,
3144 responses_only_models: vec![],
3145 request_overrides: None,
3146 extra: Default::default(),
3147 });
3148 config.features.provider_model_ref = false;
3149 config.defaults = Some(DefaultsConfig {
3150 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3151 fast: Some(bamboo_domain::ProviderModelRef::new(
3152 "anthropic",
3153 "claude-3-5-haiku",
3154 )),
3155 task_summary: None,
3156 vision: None,
3157 memory_background: Some(bamboo_domain::ProviderModelRef::new(
3158 "anthropic",
3159 "claude-3-5-haiku",
3160 )),
3161 planning: None,
3162 search: None,
3163 code_review: None,
3164 sub_agent: None,
3165 subagent_models: Default::default(),
3166 });
3167
3168 assert_eq!(
3169 config.get_memory_background_model(),
3170 Some("legacy-gpt-4o-mini".to_string())
3171 );
3172 }
3173}