1use anyhow::{Context, Result};
55use bamboo_domain::poison::PoisonRecover;
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58use std::collections::{BTreeMap, BTreeSet, HashMap};
59use std::io::Write;
60use std::path::PathBuf;
61use std::sync::{OnceLock, RwLock};
62
63use crate::keyword_masking::KeywordMaskingConfig;
64use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
65use bamboo_domain::tool_names::normalize_tool_ref;
66use bamboo_domain::ReasoningEffort;
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct EnvVarEntry {
74 pub name: String,
76 #[serde(default)]
79 pub value: String,
80 #[serde(default)]
82 pub secret: bool,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub value_encrypted: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub description: Option<String>,
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96pub struct DefaultWorkAreaConfig {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub path: Option<String>,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
104pub struct AccessControlConfig {
105 #[serde(default)]
107 pub password_enabled: bool,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub password_hash: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub password_salt: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub updated_at: Option<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub devices: Vec<DeviceCredential>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DeviceCredential {
132 pub device_id: String,
134 pub label: String,
136 pub token_hash: String,
138 pub token_salt: String,
140 pub created_at: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub last_used_at: Option<String>,
145 #[serde(default)]
148 pub revoked: bool,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct MemoryConfig {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub background_model: Option<String>,
159 #[serde(default = "default_true_auto_dream_enabled")]
164 pub auto_dream_enabled: bool,
165 #[serde(default = "default_auto_dream_interval_secs")]
169 pub auto_dream_interval_secs: u64,
170 #[serde(
172 default = "default_true_memory_project_prompt_injection",
173 alias = "memory_project_prompt_injection"
174 )]
175 pub project_prompt_injection: bool,
176 #[serde(
178 default = "default_true_memory_relevant_recall",
179 alias = "memory_relevant_recall"
180 )]
181 pub relevant_recall: bool,
182 #[serde(default, alias = "memory_relevant_recall_rerank")]
185 pub relevant_recall_rerank: bool,
186 #[serde(
188 default = "default_true_memory_project_first_dream",
189 alias = "memory_project_first_dream"
190 )]
191 pub project_first_dream: bool,
192 #[serde(
196 default = "default_true_memory_ledger_agenda",
197 alias = "memory_ledger_agenda_injection"
198 )]
199 pub ledger_agenda_injection: bool,
200 #[serde(default = "default_true_ledger_gardener_enabled")]
205 pub ledger_gardener_enabled: bool,
206 #[serde(default = "default_ledger_gardener_interval_secs")]
208 pub ledger_gardener_interval_secs: u64,
209 #[serde(default = "default_true_ledger_distillation_enabled")]
212 pub ledger_distillation_enabled: bool,
213 #[serde(default, alias = "memory_dream_refine_mode")]
220 pub dream_refine_mode: bool,
221 #[serde(
227 default = "default_true_gardener_enabled",
228 alias = "memory_gardener_enabled"
229 )]
230 pub gardener_enabled: bool,
231 #[serde(default = "default_gardener_interval_secs")]
234 pub gardener_interval_secs: u64,
235 #[serde(default = "default_gardener_volume_trigger")]
240 pub gardener_volume_trigger: usize,
241 #[serde(default = "default_gardener_max_splits_per_run")]
243 pub gardener_max_splits_per_run: usize,
244 #[serde(default = "default_gardener_min_sections")]
246 pub gardener_min_sections: usize,
247 #[serde(
252 default = "default_true_dedup_gardener_enabled",
253 alias = "memory_dedup_gardener_enabled"
254 )]
255 pub dedup_gardener_enabled: bool,
256 #[serde(default = "default_dedup_gardener_min_score")]
259 pub dedup_gardener_min_score: f64,
260 #[serde(default = "default_dedup_gardener_max_merges_per_run")]
262 pub dedup_gardener_max_merges_per_run: usize,
263 #[serde(default)]
271 pub memory_active_capacity: usize,
272 #[serde(default = "default_capacity_max_archivals_per_run")]
275 pub capacity_max_archivals_per_run: usize,
276}
277
278impl Default for MemoryConfig {
279 fn default() -> Self {
280 Self {
281 background_model: None,
282 auto_dream_enabled: default_true_auto_dream_enabled(),
283 auto_dream_interval_secs: default_auto_dream_interval_secs(),
284 project_prompt_injection: default_true_memory_project_prompt_injection(),
285 relevant_recall: default_true_memory_relevant_recall(),
286 relevant_recall_rerank: false,
287 project_first_dream: default_true_memory_project_first_dream(),
288 ledger_agenda_injection: default_true_memory_ledger_agenda(),
289 ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
290 ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
291 ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
292 dream_refine_mode: false,
293 gardener_enabled: default_true_gardener_enabled(),
294 gardener_interval_secs: default_gardener_interval_secs(),
295 gardener_volume_trigger: default_gardener_volume_trigger(),
296 gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
297 gardener_min_sections: default_gardener_min_sections(),
298 dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
299 dedup_gardener_min_score: default_dedup_gardener_min_score(),
300 dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
301 memory_active_capacity: 0,
302 capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
303 }
304 }
305}
306
307fn default_capacity_max_archivals_per_run() -> usize {
308 50
309}
310
311fn default_true_auto_dream_enabled() -> bool {
312 true
313}
314
315fn default_true_gardener_enabled() -> bool {
316 true
317}
318
319fn default_true_dedup_gardener_enabled() -> bool {
320 true
321}
322
323fn default_true_memory_ledger_agenda() -> bool {
324 true
325}
326
327fn default_true_ledger_gardener_enabled() -> bool {
328 true
329}
330
331fn default_ledger_gardener_interval_secs() -> u64 {
332 21_600
333}
334
335fn default_true_ledger_distillation_enabled() -> bool {
336 true
337}
338
339fn default_gardener_volume_trigger() -> usize {
343 25
344}
345
346fn default_gardener_interval_secs() -> u64 {
347 86_400
348}
349
350fn default_auto_dream_interval_secs() -> u64 {
351 60 * 30
352}
353
354fn default_gardener_max_splits_per_run() -> usize {
355 8
356}
357
358fn default_gardener_min_sections() -> usize {
359 5
360}
361
362fn default_dedup_gardener_min_score() -> f64 {
363 0.6
364}
365
366fn default_dedup_gardener_max_merges_per_run() -> usize {
367 8
368}
369
370fn default_true_memory_project_prompt_injection() -> bool {
371 true
372}
373
374fn default_true_memory_relevant_recall() -> bool {
375 true
376}
377
378fn default_true_memory_project_first_dream() -> bool {
379 true
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
393pub struct SubagentsConfig {
394 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub max_concurrent: Option<usize>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub worker_bin: Option<String>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub worker_args: Option<Vec<String>>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub fabric_dir: Option<String>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub executor: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub claude_code_binary: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub claude_code_model: Option<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub claude_code_permission_mode: Option<String>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub claude_code_inherit_user_config: Option<bool>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub claude_code_forward_env: Option<Vec<String>>,
440 #[serde(skip)]
447 pub broker: Option<BrokerClientConfig>,
448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub remote_placements: Vec<RemoteActorPlacement>,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
468 pub schedulable_placements: Vec<SchedulablePlacement>,
469 #[serde(default, skip_serializing_if = "Vec::is_empty")]
488 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
489}
490
491#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
494pub struct McpRoleAllowlistEntry {
495 pub role: String,
503 #[serde(default)]
511 pub tools: Vec<String>,
512}
513
514#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
530pub struct SchedulablePlacement {
531 pub role: String,
534 pub pool: String,
536 #[serde(default, skip_serializing_if = "String::is_empty")]
540 pub registry_url: String,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub token_env: Option<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub ca_cert_file: Option<String>,
550}
551
552#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
563pub struct RemoteActorPlacement {
564 pub role: String,
567 pub endpoint: String,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub token_env: Option<String>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub ca_cert_file: Option<String>,
578}
579
580#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
582pub struct BrokerClientConfig {
583 pub endpoint: String,
585 #[serde(default)]
590 pub token: String,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub token_encrypted: Option<String>,
594}
595
596#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
598pub struct DesktopChannelConfig {
599 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub enabled: Option<bool>,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
611pub struct NtfyChannelConfig {
612 #[serde(default)]
613 pub enabled: bool,
614 #[serde(default = "default_ntfy_base_url")]
616 pub base_url: String,
617 #[serde(default)]
620 pub topic: String,
621 #[serde(default, skip_serializing)]
628 pub token: Option<String>,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub token_encrypted: Option<String>,
632}
633
634impl Default for NtfyChannelConfig {
635 fn default() -> Self {
636 Self {
637 enabled: false,
638 base_url: default_ntfy_base_url(),
639 topic: String::new(),
640 token: None,
641 token_encrypted: None,
642 }
643 }
644}
645
646fn default_ntfy_base_url() -> String {
647 "https://ntfy.sh".to_string()
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
652pub struct BarkChannelConfig {
653 #[serde(default)]
654 pub enabled: bool,
655 #[serde(default = "default_bark_base_url")]
657 pub base_url: String,
658 #[serde(default, skip_serializing)]
664 pub device_key: Option<String>,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub device_key_encrypted: Option<String>,
668}
669
670impl Default for BarkChannelConfig {
671 fn default() -> Self {
672 Self {
673 enabled: false,
674 base_url: default_bark_base_url(),
675 device_key: None,
676 device_key_encrypted: None,
677 }
678 }
679}
680
681fn default_bark_base_url() -> String {
682 "https://api.day.app".to_string()
683}
684
685#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
690pub struct NotificationsConfig {
691 #[serde(default)]
692 pub desktop: DesktopChannelConfig,
693 #[serde(default)]
694 pub ntfy: NtfyChannelConfig,
695 #[serde(default)]
696 pub bark: BarkChannelConfig,
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
703pub struct ConnectPlatformConfig {
704 #[serde(rename = "type")]
708 pub platform_type: String,
709 #[serde(default, skip_serializing)]
715 pub token: Option<String>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub token_encrypted: Option<String>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub app_id: Option<String>,
723 #[serde(default, skip_serializing)]
729 pub app_secret: Option<String>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub app_secret_encrypted: Option<String>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub domain: Option<String>,
740 #[serde(default)]
745 pub allow_from: Vec<String>,
746 #[serde(default)]
750 pub admin_from: Vec<String>,
751}
752
753#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
757pub struct ConnectConfig {
758 #[serde(default)]
759 pub platforms: Vec<ConnectPlatformConfig>,
760}
761
762fn connect_config_is_empty(config: &ConnectConfig) -> bool {
763 config.platforms.is_empty()
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
773pub struct TrustedKey {
774 pub label: String,
776 pub algorithm: String,
778 pub public_key: String,
780}
781
782fn default_trusted_keys() -> Vec<TrustedKey> {
789 vec![
790 TrustedKey {
791 label: "nova (bigduu official)".to_string(),
792 algorithm: "ed25519".to_string(),
793 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
794 .to_string(),
795 },
796 TrustedKey {
797 label: "magpie (bigduu official)".to_string(),
798 algorithm: "ed25519".to_string(),
799 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
800 .to_string(),
801 },
802 ]
803}
804
805fn default_trusted_hosts() -> Vec<String> {
808 vec!["github.com/bigduu/".to_string()]
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
828pub struct PluginTrustConfig {
829 #[serde(default = "default_trusted_hosts")]
840 pub trusted_hosts: Vec<String>,
841 #[serde(default = "default_trusted_keys")]
844 pub trusted_keys: Vec<TrustedKey>,
845 #[serde(default)]
859 pub enforcement: PluginTrustEnforcement,
860}
861
862impl Default for PluginTrustConfig {
863 fn default() -> Self {
864 Self {
865 trusted_hosts: default_trusted_hosts(),
866 trusted_keys: default_trusted_keys(),
867 enforcement: PluginTrustEnforcement::default(),
868 }
869 }
870}
871
872impl PluginTrustConfig {
873 pub fn is_host_trusted(&self, url: &str) -> bool {
879 is_host_trusted(url, &self.trusted_hosts)
880 }
881
882 pub fn enforcement_is_off(&self) -> bool {
888 matches!(self.enforcement, PluginTrustEnforcement::Off)
889 }
890}
891
892#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
905#[serde(rename_all = "snake_case")]
906pub enum PluginTrustEnforcement {
907 #[default]
912 Strict,
913 Off,
917}
918
919impl<'de> Deserialize<'de> for PluginTrustEnforcement {
920 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
921 where
922 D: serde::Deserializer<'de>,
923 {
924 #[derive(Deserialize)]
925 #[serde(untagged)]
926 enum Repr {
927 Bool(bool),
928 Str(String),
929 }
930 match Repr::deserialize(deserializer)? {
931 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
932 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
933 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
934 "strict" => Ok(PluginTrustEnforcement::Strict),
935 "off" => Ok(PluginTrustEnforcement::Off),
936 other => Err(serde::de::Error::custom(format!(
937 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
938 \"off\""
939 ))),
940 },
941 }
942 }
943}
944
945struct TrustedHostEntry<'a> {
950 host: &'a str,
951 path_prefix: &'a str,
952}
953
954fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
958 match entry.find('/') {
959 Some(index) => TrustedHostEntry {
960 host: &entry[..index],
961 path_prefix: &entry[index..],
962 },
963 None => TrustedHostEntry {
964 host: entry,
965 path_prefix: "",
966 },
967 }
968}
969
970fn path_matches_prefix(path: &str, prefix: &str) -> bool {
982 if prefix.is_empty() || path == prefix {
983 return true;
984 }
985 if prefix.ends_with('/') {
986 return path.starts_with(prefix);
987 }
988 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
989}
990
991pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1015 let Ok(parsed) = url::Url::parse(url) else {
1016 return false;
1017 };
1018 if parsed.scheme() != "https" {
1019 return false;
1020 }
1021 let Some(host) = parsed.host_str() else {
1022 return false;
1023 };
1024 let host = host.to_ascii_lowercase();
1025 let path = parsed.path();
1026
1027 trusted_hosts.iter().any(|raw_entry| {
1028 let entry = raw_entry.trim().to_ascii_lowercase();
1029 let parsed_entry = parse_trusted_host_entry(&entry);
1030 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1031 })
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1039pub struct Config {
1040 #[serde(default)]
1042 pub http_proxy: String,
1043 #[serde(default)]
1045 pub https_proxy: String,
1046 #[serde(skip_serializing)]
1050 pub proxy_auth: Option<ProxyAuth>,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub proxy_auth_encrypted: Option<String>,
1057 #[serde(default)]
1059 pub headless_auth: bool,
1060
1061 #[serde(default = "default_provider")]
1063 pub provider: String,
1064
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub defaults: Option<DefaultsConfig>,
1068
1069 #[serde(default)]
1071 pub providers: ProviderConfigs,
1072
1073 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1080 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1081
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub default_provider_instance: Option<String>,
1087
1088 #[serde(default)]
1090 pub server: ServerConfig,
1091
1092 #[serde(default)]
1096 pub keyword_masking: KeywordMaskingConfig,
1097
1098 #[serde(default)]
1102 pub anthropic_model_mapping: AnthropicModelMapping,
1103
1104 #[serde(default)]
1108 pub gemini_model_mapping: GeminiModelMapping,
1109
1110 #[serde(default)]
1115 pub hooks: HooksConfig,
1116
1117 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1121 pub tools: ToolsConfig,
1122
1123 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1128 pub skills: SkillsConfig,
1129
1130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1134 pub env_vars: Vec<EnvVarEntry>,
1135
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub default_work_area: Option<DefaultWorkAreaConfig>,
1139
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1142 pub access_control: Option<AccessControlConfig>,
1143
1144 #[serde(default)]
1146 pub features: FeatureFlags,
1147
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub memory: Option<MemoryConfig>,
1151
1152 #[serde(default)]
1160 pub subagents: SubagentsConfig,
1161
1162 #[serde(
1166 default,
1167 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1168 )]
1169 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1170
1171 #[serde(default, rename = "mcpServers", alias = "mcp")]
1177 pub mcp: bamboo_domain::mcp_config::McpConfig,
1178
1179 #[serde(default)]
1184 pub notifications: NotificationsConfig,
1185
1186 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1191 pub connect: ConnectConfig,
1192
1193 #[serde(default)]
1197 pub plugin_trust: PluginTrustConfig,
1198
1199 #[serde(default, flatten)]
1205 pub extra: BTreeMap<String, Value>,
1206}
1207
1208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1212pub struct ProviderConfigs {
1213 #[serde(skip_serializing_if = "Option::is_none")]
1215 pub openai: Option<OpenAIConfig>,
1216 #[serde(skip_serializing_if = "Option::is_none")]
1218 pub anthropic: Option<AnthropicConfig>,
1219 #[serde(skip_serializing_if = "Option::is_none")]
1221 pub gemini: Option<GeminiConfig>,
1222 #[serde(skip_serializing_if = "Option::is_none")]
1224 pub copilot: Option<CopilotConfig>,
1225 #[serde(skip_serializing_if = "Option::is_none")]
1227 pub bodhi: Option<BodhiConfig>,
1228
1229 #[serde(default, flatten)]
1231 pub extra: BTreeMap<String, Value>,
1232}
1233
1234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1236pub struct FeatureFlags {
1237 #[serde(default)]
1239 pub provider_model_ref: bool,
1240 #[serde(default)]
1242 pub dynamic_model_routing: bool,
1243}
1244
1245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1249pub struct DefaultsConfig {
1250 pub chat: bamboo_domain::ProviderModelRef,
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1252 pub fast: Option<bamboo_domain::ProviderModelRef>,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1254 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub vision: Option<bamboo_domain::ProviderModelRef>,
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1258 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1262 pub planning: Option<bamboo_domain::ProviderModelRef>,
1263 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub search: Option<bamboo_domain::ProviderModelRef>,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub code_review: Option<bamboo_domain::ProviderModelRef>,
1271 #[serde(
1274 default,
1275 skip_serializing_if = "Option::is_none",
1276 alias = "sub_session"
1277 )]
1278 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
1279 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1283 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1288pub struct HooksConfig {
1289 #[serde(default)]
1291 pub image_fallback: ImageFallbackHookConfig,
1292}
1293
1294#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1301pub struct RequestOverridesConfig {
1302 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
1304 pub common: RequestScopeOverride,
1305 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1307 pub endpoints: BTreeMap<String, RequestScopeOverride>,
1308 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1310 pub rules: Vec<ModelRequestRule>,
1311}
1312
1313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1315pub struct ModelRequestRule {
1316 pub model_pattern: String,
1318 #[serde(default, skip_serializing_if = "Option::is_none")]
1320 pub endpoint: Option<String>,
1321 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
1323 pub scope: RequestScopeOverride,
1324}
1325
1326#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1328pub struct RequestScopeOverride {
1329 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1331 pub headers: BTreeMap<String, TemplateExpr>,
1332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1334 pub body_patch: Vec<BodyPatch>,
1335}
1336
1337impl RequestScopeOverride {
1338 pub fn is_empty(&self) -> bool {
1339 self.headers.is_empty() && self.body_patch.is_empty()
1340 }
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1345pub struct BodyPatch {
1346 pub path: String,
1348 #[serde(default)]
1350 pub op: BodyPatchOp,
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1353 pub value: Option<PatchValue>,
1354}
1355
1356#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1358#[serde(rename_all = "snake_case")]
1359pub enum BodyPatchOp {
1360 #[default]
1361 Set,
1362 Remove,
1363}
1364
1365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1367#[serde(untagged)]
1368pub enum PatchValue {
1369 Template(TemplateExpr),
1370 Json(Value),
1371}
1372
1373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1375#[serde(untagged)]
1376pub enum TemplateExpr {
1377 Literal(String),
1379 Structured(TemplateExprSpec),
1381}
1382
1383#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1385#[serde(tag = "type", rename_all = "snake_case")]
1386pub enum TemplateExprSpec {
1387 Literal { value: String },
1389 EnvRef {
1391 name: String,
1392 #[serde(default, skip_serializing_if = "Option::is_none")]
1393 fallback: Option<String>,
1394 },
1395 Generated { generator: GeneratedValue },
1397 Format { template: String },
1399}
1400
1401#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1403#[serde(rename_all = "snake_case")]
1404pub enum GeneratedValue {
1405 Uuid,
1406 UnixMs,
1407}
1408
1409#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1411pub struct ToolsConfig {
1412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1414 pub disabled: Vec<String>,
1415}
1416
1417impl ToolsConfig {
1418 fn is_empty(&self) -> bool {
1419 self.disabled.is_empty()
1420 }
1421}
1422
1423#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1425pub struct SkillsConfig {
1426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1428 pub disabled: Vec<String>,
1429}
1430
1431impl SkillsConfig {
1432 fn is_empty(&self) -> bool {
1433 self.disabled.is_empty()
1434 }
1435}
1436
1437#[derive(Debug, Clone, Serialize, Deserialize)]
1442pub struct ImageFallbackHookConfig {
1443 #[serde(default = "default_true_hooks")]
1444 pub enabled: bool,
1445
1446 #[serde(default = "default_image_fallback_mode")]
1448 pub mode: String,
1449}
1450
1451impl Default for ImageFallbackHookConfig {
1452 fn default() -> Self {
1453 Self {
1454 enabled: default_true_hooks(),
1455 mode: default_image_fallback_mode(),
1456 }
1457 }
1458}
1459
1460fn default_image_fallback_mode() -> String {
1461 "placeholder".to_string()
1462}
1463
1464fn default_true_hooks() -> bool {
1465 false
1468}
1469
1470#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1482pub struct OpenAIConfig {
1483 #[serde(default, skip_serializing)]
1487 pub api_key: String,
1488 #[serde(default, skip_serializing_if = "Option::is_none")]
1490 pub api_key_encrypted: Option<String>,
1491 #[serde(skip)]
1496 pub api_key_from_env: bool,
1497 #[serde(skip_serializing_if = "Option::is_none")]
1499 pub base_url: Option<String>,
1500 #[serde(skip_serializing_if = "Option::is_none")]
1502 pub model: Option<String>,
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1506 pub fast_model: Option<String>,
1507 #[serde(default, skip_serializing_if = "Option::is_none")]
1510 pub vision_model: Option<String>,
1511 #[serde(skip_serializing_if = "Option::is_none")]
1513 pub reasoning_effort: Option<ReasoningEffort>,
1514
1515 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1522 pub responses_only_models: Vec<String>,
1523 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub request_overrides: Option<RequestOverridesConfig>,
1526
1527 #[serde(default, flatten)]
1529 pub extra: BTreeMap<String, Value>,
1530}
1531
1532#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1544pub struct AnthropicConfig {
1545 #[serde(default, skip_serializing)]
1549 pub api_key: String,
1550 #[serde(default, skip_serializing_if = "Option::is_none")]
1552 pub api_key_encrypted: Option<String>,
1553 #[serde(skip)]
1558 pub api_key_from_env: bool,
1559 #[serde(skip_serializing_if = "Option::is_none")]
1561 pub base_url: Option<String>,
1562 #[serde(skip_serializing_if = "Option::is_none")]
1564 pub model: Option<String>,
1565 #[serde(default, skip_serializing_if = "Option::is_none")]
1568 pub fast_model: Option<String>,
1569 #[serde(default, skip_serializing_if = "Option::is_none")]
1572 pub vision_model: Option<String>,
1573 #[serde(skip_serializing_if = "Option::is_none")]
1575 pub max_tokens: Option<u32>,
1576 #[serde(skip_serializing_if = "Option::is_none")]
1578 pub reasoning_effort: Option<ReasoningEffort>,
1579 #[serde(default, skip_serializing_if = "Option::is_none")]
1581 pub request_overrides: Option<RequestOverridesConfig>,
1582
1583 #[serde(default, flatten)]
1585 pub extra: BTreeMap<String, Value>,
1586}
1587
1588#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1599pub struct GeminiConfig {
1600 #[serde(default, skip_serializing)]
1604 pub api_key: String,
1605 #[serde(default, skip_serializing_if = "Option::is_none")]
1607 pub api_key_encrypted: Option<String>,
1608 #[serde(skip)]
1613 pub api_key_from_env: bool,
1614 #[serde(skip_serializing_if = "Option::is_none")]
1616 pub base_url: Option<String>,
1617 #[serde(skip_serializing_if = "Option::is_none")]
1619 pub model: Option<String>,
1620 #[serde(default, skip_serializing_if = "Option::is_none")]
1623 pub fast_model: Option<String>,
1624 #[serde(default, skip_serializing_if = "Option::is_none")]
1627 pub vision_model: Option<String>,
1628 #[serde(skip_serializing_if = "Option::is_none")]
1630 pub reasoning_effort: Option<ReasoningEffort>,
1631 #[serde(default, skip_serializing_if = "Option::is_none")]
1633 pub request_overrides: Option<RequestOverridesConfig>,
1634
1635 #[serde(default, flatten)]
1637 pub extra: BTreeMap<String, Value>,
1638}
1639
1640#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1652pub struct CopilotConfig {
1653 #[serde(default)]
1655 pub enabled: bool,
1656 #[serde(default)]
1658 pub headless_auth: bool,
1659 #[serde(skip_serializing_if = "Option::is_none")]
1661 pub model: Option<String>,
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub fast_model: Option<String>,
1666 #[serde(default, skip_serializing_if = "Option::is_none")]
1669 pub vision_model: Option<String>,
1670 #[serde(skip_serializing_if = "Option::is_none")]
1672 pub reasoning_effort: Option<ReasoningEffort>,
1673
1674 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1683 pub responses_only_models: Vec<String>,
1684 #[serde(default, skip_serializing_if = "Option::is_none")]
1686 pub request_overrides: Option<RequestOverridesConfig>,
1687
1688 #[serde(default, flatten)]
1690 pub extra: BTreeMap<String, Value>,
1691}
1692
1693#[derive(Debug, Clone, Serialize, Deserialize)]
1698pub struct BodhiConfig {
1699 #[serde(default, skip_serializing)]
1701 pub api_key: String,
1702 #[serde(default, skip_serializing_if = "Option::is_none")]
1704 pub api_key_encrypted: Option<String>,
1705 #[serde(skip_serializing_if = "Option::is_none")]
1707 pub base_url: Option<String>,
1708 #[serde(skip_serializing_if = "Option::is_none")]
1710 pub target_provider: Option<String>,
1711 #[serde(skip_serializing_if = "Option::is_none")]
1713 pub reasoning_effort: Option<ReasoningEffort>,
1714
1715 #[serde(default, flatten)]
1717 pub extra: BTreeMap<String, Value>,
1718}
1719
1720fn default_provider() -> String {
1722 "anthropic".to_string()
1723}
1724
1725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1757pub struct ProviderInstanceConfig {
1758 pub provider_type: String,
1763
1764 #[serde(default, skip_serializing_if = "Option::is_none")]
1766 pub label: Option<String>,
1767
1768 #[serde(default, skip_serializing)]
1770 pub api_key: String,
1771
1772 #[serde(default, skip_serializing_if = "Option::is_none")]
1775 pub api_key_encrypted: Option<String>,
1776
1777 #[serde(default, skip_serializing_if = "Option::is_none")]
1779 pub base_url: Option<String>,
1780
1781 #[serde(default, skip_serializing_if = "Option::is_none")]
1783 pub model: Option<String>,
1784
1785 #[serde(default, skip_serializing_if = "Option::is_none")]
1787 pub fast_model: Option<String>,
1788
1789 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 pub vision_model: Option<String>,
1792
1793 #[serde(default, skip_serializing_if = "Option::is_none")]
1795 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
1796
1797 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1799 pub responses_only_models: Vec<String>,
1800
1801 #[serde(default, skip_serializing_if = "Option::is_none")]
1803 pub request_overrides: Option<RequestOverridesConfig>,
1804
1805 #[serde(default = "default_true")]
1808 pub enabled: bool,
1809
1810 #[serde(default, flatten)]
1812 pub extra: BTreeMap<String, Value>,
1813}
1814
1815fn default_true() -> bool {
1816 true
1817}
1818
1819fn default_port() -> u16 {
1821 9562
1822}
1823
1824fn default_bind() -> String {
1826 "127.0.0.1".to_string()
1827}
1828
1829fn default_workers() -> usize {
1831 10
1832}
1833
1834fn default_data_dir() -> PathBuf {
1836 super::paths::bamboo_dir()
1837}
1838
1839#[derive(Debug, Clone, Serialize, Deserialize)]
1841pub struct ServerConfig {
1842 #[serde(default = "default_port")]
1844 pub port: u16,
1845
1846 #[serde(default = "default_bind")]
1848 pub bind: String,
1849
1850 pub static_dir: Option<PathBuf>,
1852
1853 #[serde(default = "default_workers")]
1855 pub workers: usize,
1856
1857 #[serde(default, skip_serializing_if = "Option::is_none")]
1865 pub tls: Option<TlsConfig>,
1866
1867 #[serde(default, flatten)]
1869 pub extra: BTreeMap<String, Value>,
1870}
1871
1872impl Default for ServerConfig {
1873 fn default() -> Self {
1874 Self {
1875 port: default_port(),
1876 bind: default_bind(),
1877 static_dir: None,
1878 workers: default_workers(),
1879 tls: None,
1880 extra: BTreeMap::new(),
1881 }
1882 }
1883}
1884
1885#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1891pub struct TlsConfig {
1892 pub cert_file: PathBuf,
1894 pub key_file: PathBuf,
1896}
1897
1898#[derive(Debug, Clone, Serialize, Deserialize)]
1900pub struct ProxyAuth {
1901 pub username: String,
1903 pub password: String,
1905}
1906
1907fn parse_bool_env(value: &str) -> bool {
1911 matches!(
1912 value.trim().to_ascii_lowercase().as_str(),
1913 "1" | "true" | "yes" | "y" | "on"
1914 )
1915}
1916
1917fn expand_user_path(value: &str) -> PathBuf {
1918 let trimmed = value.trim();
1919 if let Some(rest) = trimmed.strip_prefix("~/") {
1920 if let Some(home) = dirs::home_dir() {
1921 return home.join(rest);
1922 }
1923 }
1924 PathBuf::from(trimmed)
1925}
1926
1927impl Default for Config {
1928 fn default() -> Self {
1929 Self::create_default()
1938 }
1939}
1940
1941#[derive(Debug, Clone, PartialEq, Eq)]
1943pub struct PromptSafeEnvVarEntry {
1944 pub name: String,
1945 pub secret: bool,
1946 pub description: Option<String>,
1947}
1948
1949static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
1953
1954static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
1955
1956fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
1957 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
1958}
1959
1960fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
1961 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
1962}
1963
1964impl Config {
1965 pub fn new() -> Self {
1984 Self::from_data_dir(None)
1985 }
1986
1987 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
1996 let data_dir = data_dir
1998 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
1999 .unwrap_or_else(default_data_dir);
2000
2001 let config_path = data_dir.join("config.json");
2002
2003 let mut config = if config_path.exists() {
2004 if let Ok(content) = std::fs::read_to_string(&config_path) {
2005 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
2006 tracing::warn!(
2013 "Failed to parse config.json ({}); quarantining it and attempting recovery",
2014 e
2015 );
2016 quarantine_corrupt_config(&config_path);
2017 Self::salvage_partial(&content, &data_dir)
2018 .or_else(|| Self::load_backup(&data_dir))
2019 .unwrap_or_else(|| {
2020 tracing::warn!(
2021 "Could not salvage and no usable config.json.bak; using defaults"
2022 );
2023 Self::create_default()
2024 })
2025 })
2026 } else {
2027 Self::create_default()
2028 }
2029 } else {
2030 Self::create_default()
2031 };
2032
2033 config.hydrate_proxy_auth_from_encrypted();
2035 config.hydrate_provider_api_keys_from_encrypted();
2037 config.hydrate_provider_instance_api_keys_from_encrypted();
2039 config.hydrate_mcp_secrets_from_encrypted();
2041 config.hydrate_env_vars_from_encrypted();
2043 config.hydrate_cluster_fabric_from_encrypted();
2045 config.hydrate_broker_token_from_encrypted();
2047 config.hydrate_notifications_from_encrypted();
2049 config.merge_connect_config(&data_dir);
2054 config.hydrate_connect_platform_tokens_from_encrypted();
2056 config.normalize_tool_settings();
2057 config.normalize_skill_settings();
2058 config.normalize_plugin_trust_settings();
2059
2060 config.extra.remove("data_dir");
2063
2064 if apply_env {
2068 config.apply_env_overrides();
2069 }
2070
2071 if publish {
2075 config.publish_env_vars();
2076 }
2077
2078 config
2079 }
2080
2081 fn apply_env_overrides(&mut self) {
2085 if let Ok(port) = std::env::var("BAMBOO_PORT") {
2086 if let Ok(port) = port.parse() {
2087 self.server.port = port;
2088 }
2089 }
2090
2091 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
2092 self.server.bind = bind;
2093 }
2094
2095 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
2097 self.provider = provider;
2098 }
2099
2100 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
2101 self.headless_auth = parse_bool_env(&headless);
2102 }
2103
2104 if let Ok(project_prompt_injection) =
2105 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
2106 {
2107 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2108 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
2109 }
2110
2111 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
2112 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2113 memory.relevant_recall = parse_bool_env(&relevant_recall);
2114 }
2115
2116 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
2117 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2118 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
2119 }
2120
2121 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
2122 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2123 memory.project_first_dream = parse_bool_env(&project_first_dream);
2124 }
2125
2126 if let Ok(key) = std::env::var("BAMBOO_OPENAI_API_KEY") {
2133 let key = key.trim();
2134 if !key.is_empty() {
2135 let openai = self
2136 .providers
2137 .openai
2138 .get_or_insert_with(OpenAIConfig::default);
2139 openai.api_key = key.to_string();
2140 openai.api_key_from_env = true;
2141 }
2142 }
2143 if let Ok(key) = std::env::var("BAMBOO_ANTHROPIC_API_KEY") {
2144 let key = key.trim();
2145 if !key.is_empty() {
2146 let anthropic = self
2147 .providers
2148 .anthropic
2149 .get_or_insert_with(AnthropicConfig::default);
2150 anthropic.api_key = key.to_string();
2151 anthropic.api_key_from_env = true;
2152 }
2153 }
2154 if let Ok(key) = std::env::var("BAMBOO_GEMINI_API_KEY") {
2155 let key = key.trim();
2156 if !key.is_empty() {
2157 let gemini = self
2158 .providers
2159 .gemini
2160 .get_or_insert_with(GeminiConfig::default);
2161 gemini.api_key = key.to_string();
2162 gemini.api_key_from_env = true;
2163 }
2164 }
2165 }
2166
2167 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
2173 Self::from_data_dir_impl(data_dir, true, true)
2174 }
2175
2176 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
2180 Self::from_data_dir_impl(data_dir, false, true)
2181 }
2182
2183 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
2190 Self::from_data_dir_impl(data_dir, false, false)
2191 }
2192
2193 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
2221 let connect_path = data_dir.join("connect.json");
2222 match std::fs::read_to_string(&connect_path) {
2223 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
2224 Ok(connect) => {
2225 let legacy_key_present = !connect_config_is_empty(&self.connect);
2226 if legacy_key_present {
2227 tracing::warn!(
2228 "config.json still has a legacy `connect` key alongside \
2229 connect.json; connect.json takes precedence — dropping the \
2230 stale key from config.json now"
2231 );
2232 }
2233 self.connect = connect;
2234 if legacy_key_present {
2235 strip_legacy_connect_key_from_config_json(data_dir);
2236 }
2237 }
2238 Err(e) => {
2239 tracing::error!(
2240 "Failed to parse {:?} ({}); continuing with an empty (inert) \
2241 connect config instead of falling back to a legacy config.json copy",
2242 connect_path,
2243 e
2244 );
2245 quarantine_corrupt_connect(&connect_path);
2246 self.connect = ConnectConfig::default();
2247 }
2248 },
2249 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2250 if !connect_config_is_empty(&self.connect) {
2251 tracing::info!(
2252 "Migrating legacy `connect` config from config.json to a \
2253 standalone connect.json"
2254 );
2255 strip_legacy_connect_key_from_config_json(data_dir);
2259 if let Err(e) = save_connect_config(&self.connect, data_dir) {
2260 tracing::error!("Failed to write connect.json during migration: {}", e);
2261 }
2262 }
2263 }
2264 Err(e) => {
2265 tracing::error!(
2266 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
2267 connect_path,
2268 e
2269 );
2270 self.connect = ConnectConfig::default();
2271 }
2272 }
2273 }
2274
2275 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
2278 serde_json::from_str::<Config>(content).map(|mut config| {
2279 config.hydrate_proxy_auth_from_encrypted();
2280 config.hydrate_provider_api_keys_from_encrypted();
2281 config.hydrate_provider_instance_api_keys_from_encrypted();
2282 config.hydrate_mcp_secrets_from_encrypted();
2283 config.hydrate_env_vars_from_encrypted();
2284 config.hydrate_cluster_fabric_from_encrypted();
2285 config.hydrate_broker_token_from_encrypted();
2286 config.hydrate_notifications_from_encrypted();
2287 config.hydrate_connect_platform_tokens_from_encrypted();
2288 config.normalize_tool_settings();
2289 config.normalize_skill_settings();
2290 config
2291 })
2292 }
2293
2294 fn load_backup(data_dir: &std::path::Path) -> Option<Self> {
2299 let config_path = data_dir.join("config.json");
2300 for gen in 0..BAK_GENERATIONS {
2301 let backup = backup_path_for(&config_path, gen);
2302 let Ok(content) = std::fs::read_to_string(&backup) else {
2303 continue;
2304 };
2305 match Self::parse_and_hydrate(&content) {
2306 Ok(config) => {
2307 tracing::info!("Recovered configuration from {:?}", backup);
2308 return Some(config);
2309 }
2310 Err(e) => {
2311 tracing::warn!(
2312 "Backup {:?} is unparseable ({}); trying an older generation",
2313 backup,
2314 e
2315 );
2316 }
2317 }
2318 }
2319 None
2320 }
2321
2322 const SALVAGE_MAX_KEYS: usize = 512;
2328
2329 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<Self> {
2352 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
2355 let corrupt_obj = corrupt.as_object()?;
2356 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
2357 tracing::warn!(
2358 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
2359 corrupt_obj.len(),
2360 Self::SALVAGE_MAX_KEYS
2361 );
2362 return None;
2363 }
2364
2365 let mut base = Self::load_backup(data_dir)
2369 .and_then(|backup| serde_json::to_value(backup).ok())
2370 .or_else(|| serde_json::to_value(Self::create_default()).ok())?;
2371 let base_obj = base.as_object_mut()?;
2372
2373 let mut salvaged: Vec<String> = Vec::new();
2374 for (key, value) in corrupt_obj {
2375 let previous = base_obj.insert(key.clone(), value.clone());
2376 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
2380 salvaged.push(key.clone());
2381 } else {
2382 match previous {
2383 Some(prev) => {
2384 base_obj.insert(key.clone(), prev);
2385 }
2386 None => {
2387 base_obj.remove(key);
2388 }
2389 }
2390 }
2391 }
2392
2393 tracing::warn!(
2394 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
2395 last-known-good/default value",
2396 salvaged.len(),
2397 salvaged.join(", ")
2398 );
2399
2400 let rebuilt = serde_json::to_string(&base).ok()?;
2404 Self::parse_and_hydrate(&rebuilt).ok()
2405 }
2406
2407 pub fn get_model(&self) -> Option<String> {
2415 if self.features.provider_model_ref {
2416 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
2417 return Some(model_ref.model.clone());
2418 }
2419 }
2420 match self.provider.as_str() {
2421 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
2422 "anthropic" => self
2423 .providers
2424 .anthropic
2425 .as_ref()
2426 .and_then(|c| c.model.clone()),
2427 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
2428 "copilot" => Some(
2429 self.providers
2430 .copilot
2431 .as_ref()
2432 .and_then(|c| c.model.clone())
2433 .unwrap_or_else(|| "gpt-4o".to_string()),
2434 ),
2435 _ => None,
2436 }
2437 }
2438
2439 pub fn get_fast_model(&self) -> Option<String> {
2447 if self.features.provider_model_ref {
2448 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
2449 return Some(model_ref.model.clone());
2450 }
2451 }
2452 let fast = match self.provider.as_str() {
2453 "openai" => self
2454 .providers
2455 .openai
2456 .as_ref()
2457 .and_then(|c| c.fast_model.clone()),
2458 "anthropic" => self
2459 .providers
2460 .anthropic
2461 .as_ref()
2462 .and_then(|c| c.fast_model.clone()),
2463 "gemini" => self
2464 .providers
2465 .gemini
2466 .as_ref()
2467 .and_then(|c| c.fast_model.clone()),
2468 "copilot" => self
2469 .providers
2470 .copilot
2471 .as_ref()
2472 .and_then(|c| c.fast_model.clone()),
2473 _ => None,
2474 };
2475 fast.or_else(|| self.get_model())
2476 }
2477
2478 pub fn get_task_summary_model(&self) -> Option<String> {
2486 if self.features.provider_model_ref {
2487 if let Some(model_ref) = self
2488 .defaults
2489 .as_ref()
2490 .and_then(|d| d.task_summary.as_ref())
2491 .or_else(|| {
2492 self.defaults
2493 .as_ref()
2494 .and_then(|d| d.memory_background.as_ref())
2495 })
2496 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
2497 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
2498 {
2499 return Some(model_ref.model.clone());
2500 }
2501 }
2502
2503 self.get_memory_background_model()
2504 .or_else(|| self.get_model())
2505 }
2506
2507 pub fn get_memory_background_model(&self) -> Option<String> {
2519 if self.features.provider_model_ref {
2520 if let Some(model_ref) = self
2521 .defaults
2522 .as_ref()
2523 .and_then(|d| d.memory_background.as_ref())
2524 {
2525 return Some(model_ref.model.clone());
2526 }
2527 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
2528 return Some(model_ref.model.clone());
2529 }
2530 }
2531 let configured = self
2532 .memory
2533 .as_ref()
2534 .and_then(|memory| memory.background_model.as_ref())
2535 .map(|value| value.trim())
2536 .filter(|value| !value.is_empty())
2537 .map(ToString::to_string);
2538 configured.or_else(|| match self.provider.as_str() {
2539 "openai" => self
2540 .providers
2541 .openai
2542 .as_ref()
2543 .and_then(|c| c.fast_model.clone()),
2544 "anthropic" => self
2545 .providers
2546 .anthropic
2547 .as_ref()
2548 .and_then(|c| c.fast_model.clone()),
2549 "gemini" => self
2550 .providers
2551 .gemini
2552 .as_ref()
2553 .and_then(|c| c.fast_model.clone()),
2554 "copilot" => self
2555 .providers
2556 .copilot
2557 .as_ref()
2558 .and_then(|c| c.fast_model.clone()),
2559 _ => None,
2560 })
2561 }
2562
2563 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
2571 let raw = self
2572 .default_work_area
2573 .as_ref()
2574 .and_then(|config| config.path.as_ref())
2575 .map(|value| value.trim())
2576 .filter(|value| !value.is_empty())?;
2577
2578 let candidate = expand_user_path(raw);
2579 if candidate.is_absolute() {
2580 let canonical = std::fs::canonicalize(&candidate).ok();
2581 return canonical
2582 .as_ref()
2583 .filter(|path| path.is_dir())
2584 .map(|_| candidate.clone())
2585 .or_else(|| candidate.is_dir().then_some(candidate));
2586 }
2587
2588 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
2589 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
2590 canonical
2591 .as_ref()
2592 .filter(|path| path.is_dir())
2593 .map(|_| from_bamboo_dir.clone())
2594 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
2595 .or_else(|| candidate.is_dir().then_some(candidate))
2596 }
2597
2598 pub fn get_vision_model(&self) -> Option<String> {
2603 let vision = match self.provider.as_str() {
2604 "openai" => self
2605 .providers
2606 .openai
2607 .as_ref()
2608 .and_then(|c| c.vision_model.clone()),
2609 "anthropic" => self
2610 .providers
2611 .anthropic
2612 .as_ref()
2613 .and_then(|c| c.vision_model.clone()),
2614 "gemini" => self
2615 .providers
2616 .gemini
2617 .as_ref()
2618 .and_then(|c| c.vision_model.clone()),
2619 "copilot" => self
2620 .providers
2621 .copilot
2622 .as_ref()
2623 .and_then(|c| c.vision_model.clone()),
2624 _ => None,
2625 };
2626 vision.or_else(|| self.get_model())
2627 }
2628
2629 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
2631 self.reasoning_effort_for_key(&self.provider)
2632 }
2633
2634 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
2644 let trimmed = key.trim();
2645 if trimmed.is_empty() {
2646 return None;
2647 }
2648
2649 if let Some(instance) = self.provider_instances.get(trimmed) {
2651 return instance.reasoning_effort;
2652 }
2653
2654 match trimmed {
2656 "openai" => self
2657 .providers
2658 .openai
2659 .as_ref()
2660 .and_then(|c| c.reasoning_effort),
2661 "anthropic" => self
2662 .providers
2663 .anthropic
2664 .as_ref()
2665 .and_then(|c| c.reasoning_effort),
2666 "gemini" => self
2667 .providers
2668 .gemini
2669 .as_ref()
2670 .and_then(|c| c.reasoning_effort),
2671 "copilot" => self
2672 .providers
2673 .copilot
2674 .as_ref()
2675 .and_then(|c| c.reasoning_effort),
2676 "bodhi" => self
2677 .providers
2678 .bodhi
2679 .as_ref()
2680 .and_then(|c| c.reasoning_effort),
2681 _ => None,
2682 }
2683 }
2684
2685 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
2687 self.tools
2688 .disabled
2689 .iter()
2690 .map(|name| name.trim())
2691 .filter(|name| !name.is_empty())
2692 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
2693 .collect()
2694 }
2695
2696 pub fn normalize_tool_settings(&mut self) {
2698 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
2699 }
2700
2701 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
2703 self.skills
2704 .disabled
2705 .iter()
2706 .map(|id| id.trim())
2707 .filter(|id| !id.is_empty())
2708 .map(|id| id.to_string())
2709 .collect()
2710 }
2711
2712 pub fn normalize_skill_settings(&mut self) {
2714 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
2715 }
2716
2717 pub fn normalize_plugin_trust_settings(&mut self) {
2725 self.plugin_trust.trusted_hosts = self
2726 .plugin_trust
2727 .trusted_hosts
2728 .iter()
2729 .map(|entry| entry.trim().to_ascii_lowercase())
2730 .filter(|entry| !entry.is_empty())
2731 .collect();
2732 }
2733
2734 pub fn effective_default_provider(&self) -> &str {
2739 self.default_provider_instance
2740 .as_deref()
2741 .unwrap_or(&self.provider)
2742 }
2743
2744 pub fn has_provider_instances(&self) -> bool {
2746 !self.provider_instances.is_empty()
2747 }
2748
2749 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
2751 self.env_vars
2752 .iter()
2753 .filter(|e| !e.value.trim().is_empty())
2754 .map(|e| (e.name.clone(), e.value.clone()))
2755 .collect()
2756 }
2757
2758 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
2759 self.env_vars
2760 .iter()
2761 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
2762 .map(|entry| PromptSafeEnvVarEntry {
2763 name: entry.name.clone(),
2764 secret: entry.secret,
2765 description: entry
2766 .description
2767 .as_ref()
2768 .map(|value| value.trim().to_string())
2769 .filter(|value| !value.is_empty()),
2770 })
2771 .collect()
2772 }
2773
2774 pub fn publish_env_vars(&self) {
2776 let map = self.env_vars_as_map();
2777 let mut env_guard = env_vars_cache().write().recover_poison();
2778 *env_guard = map;
2779
2780 let prompt_safe = self.prompt_safe_env_vars();
2781 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
2782 *prompt_guard = prompt_safe;
2783 }
2784
2785 pub fn current_env_vars() -> HashMap<String, String> {
2787 env_vars_cache().read().recover_poison().clone()
2788 }
2789
2790 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
2792 prompt_safe_env_vars_cache().read().recover_poison().clone()
2793 }
2794
2795 fn create_default() -> Self {
2797 Config {
2798 http_proxy: String::new(),
2799 https_proxy: String::new(),
2800 proxy_auth: None,
2801 proxy_auth_encrypted: None,
2802 headless_auth: false,
2803 subagents: SubagentsConfig::default(),
2804 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2805 provider: default_provider(),
2806 providers: ProviderConfigs::default(),
2807 provider_instances: HashMap::new(),
2808 default_provider_instance: None,
2809 server: ServerConfig::default(),
2810 keyword_masking: KeywordMaskingConfig::default(),
2811 anthropic_model_mapping: AnthropicModelMapping::default(),
2812 gemini_model_mapping: GeminiModelMapping::default(),
2813 hooks: HooksConfig::default(),
2814 tools: ToolsConfig::default(),
2815 skills: SkillsConfig::default(),
2816 env_vars: Vec::new(),
2817 default_work_area: None,
2818 access_control: None,
2819 features: FeatureFlags::default(),
2820 defaults: None,
2821 memory: None,
2822 mcp: bamboo_domain::mcp_config::McpConfig::default(),
2823 notifications: NotificationsConfig::default(),
2824 connect: ConnectConfig::default(),
2825 plugin_trust: PluginTrustConfig::default(),
2826 extra: BTreeMap::new(),
2827 }
2828 }
2829
2830 pub fn server_addr(&self) -> String {
2832 format!("{}:{}", self.server.bind, self.server.port)
2833 }
2834
2835 pub fn save(&self) -> Result<()> {
2837 self.save_to_dir(default_data_dir())
2838 }
2839
2840 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
2844 let path = data_dir.join("config.json");
2845
2846 if let Some(parent) = path.parent() {
2847 std::fs::create_dir_all(parent)
2848 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
2849 }
2850
2851 let mut to_save = self.clone();
2852 to_save.extra.remove("data_dir");
2854 to_save.extra.remove("model");
2856 to_save.refresh_proxy_auth_encrypted()?;
2857 to_save.refresh_provider_api_keys_encrypted()?;
2858 to_save.refresh_provider_instance_api_keys_encrypted()?;
2859 to_save.refresh_env_vars_encrypted()?;
2860 to_save.sanitize_env_vars_for_disk();
2861 to_save.refresh_cluster_fabric_encrypted()?;
2862 to_save.sanitize_cluster_fabric_for_disk();
2863 to_save.refresh_notifications_encrypted()?;
2866 to_save.refresh_connect_platform_tokens_encrypted()?;
2867 to_save.normalize_tool_settings();
2868 to_save.normalize_skill_settings();
2869
2870 let mut config_value =
2880 serde_json::to_value(&to_save).context("Failed to serialize config to JSON")?;
2881 if let Some(obj) = config_value.as_object_mut() {
2882 obj.remove("connect");
2883 }
2884 let content = serde_json::to_string_pretty(&config_value)
2885 .context("Failed to serialize config to JSON")?;
2886
2887 if path.exists()
2894 && std::fs::read_to_string(&path)
2895 .ok()
2896 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
2897 {
2898 rotate_backups(&path, BAK_GENERATIONS);
2902 let backup = backup_path_for(&path, 0);
2903 if let Err(e) = std::fs::copy(&path, &backup) {
2904 tracing::warn!("Failed to back up config.json before save: {}", e);
2905 }
2906 }
2907
2908 write_atomic(&path, content.as_bytes())
2909 .with_context(|| format!("Failed to write config file: {:?}", path))?;
2910
2911 save_connect_config(&to_save.connect, &data_dir)?;
2912
2913 Ok(())
2914 }
2915}
2916
2917fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
2927 let path = data_dir.join("connect.json");
2928 if connect_config_is_empty(connect) && !path.exists() {
2929 return Ok(());
2930 }
2931
2932 if path.exists() {
2933 let backup = path.with_extension("json.bak");
2934 if let Err(e) = std::fs::copy(&path, &backup) {
2935 tracing::warn!("Failed to back up connect.json before save: {}", e);
2936 }
2937 }
2938
2939 let content = serde_json::to_string_pretty(connect)
2940 .context("Failed to serialize connect config to JSON")?;
2941 write_atomic(&path, content.as_bytes())
2942 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
2943 Ok(())
2944}
2945
2946fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
2964 let config_path = data_dir.join("config.json");
2965 let content = match std::fs::read_to_string(&config_path) {
2966 Ok(content) => content,
2967 Err(e) => {
2968 tracing::error!(
2969 "Failed to read config.json to strip legacy `connect` key: {}",
2970 e
2971 );
2972 return;
2973 }
2974 };
2975 let mut value: serde_json::Value = match serde_json::from_str(&content) {
2976 Ok(value) => value,
2977 Err(e) => {
2978 tracing::error!(
2979 "Failed to parse config.json to strip legacy `connect` key: {}",
2980 e
2981 );
2982 return;
2983 }
2984 };
2985 let Some(obj) = value.as_object_mut() else {
2986 return;
2987 };
2988 if obj.remove("connect").is_none() {
2989 return;
2992 }
2993 let rewritten = match serde_json::to_string_pretty(&value) {
2994 Ok(rewritten) => rewritten,
2995 Err(e) => {
2996 tracing::error!(
2997 "Failed to serialize config.json after stripping legacy `connect` key: {}",
2998 e
2999 );
3000 return;
3001 }
3002 };
3003 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
3004 tracing::error!(
3005 "Failed to write config.json after stripping legacy `connect` key: {}",
3006 e
3007 );
3008 }
3009}
3010
3011fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
3026 let backup = connect_path.with_extension("json.bak");
3027 match std::fs::rename(connect_path, &backup) {
3028 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
3029 Err(e) => {
3030 tracing::warn!(
3031 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
3032 backup,
3033 e
3034 );
3035 if let Err(e) = std::fs::copy(connect_path, &backup) {
3036 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
3037 return;
3038 }
3039 if let Err(e) = std::fs::remove_file(connect_path) {
3040 tracing::error!(
3041 "Quarantined corrupt connect.json to {:?} but failed to remove the \
3042 original {:?}: {}",
3043 backup,
3044 connect_path,
3045 e
3046 );
3047 }
3048 }
3049 }
3050}
3051
3052const QUARANTINE_KEEP: usize = 5;
3055
3056fn quarantine_corrupt_config(config_path: &std::path::Path) {
3060 let nanos = std::time::SystemTime::now()
3061 .duration_since(std::time::UNIX_EPOCH)
3062 .map(|d| d.as_nanos())
3063 .unwrap_or(0);
3064 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
3068 let mut dedup = 1u32;
3069 while quarantine.exists() {
3070 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
3071 dedup += 1;
3072 }
3073 match std::fs::copy(config_path, &quarantine) {
3074 Ok(_) => tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine),
3075 Err(e) => tracing::error!("Failed to quarantine corrupt config.json: {}", e),
3076 }
3077 prune_quarantine_files(config_path, QUARANTINE_KEEP);
3078}
3079
3080fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
3083 let Some(dir) = config_path.parent() else {
3084 return;
3085 };
3086 let prefix = "config.json.corrupted.";
3087 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
3088 Ok(entries) => entries
3089 .filter_map(|e| e.ok())
3090 .map(|e| e.path())
3091 .filter(|p| {
3092 p.file_name()
3093 .and_then(|n| n.to_str())
3094 .is_some_and(|n| n.starts_with(prefix))
3095 })
3096 .collect(),
3097 Err(_) => return,
3098 };
3099 if quarantines.len() <= keep {
3100 return;
3101 }
3102 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
3104 let remove = quarantines.len() - keep;
3105 for stale in quarantines.into_iter().take(remove) {
3106 if let Err(e) = std::fs::remove_file(&stale) {
3107 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
3108 }
3109 }
3110}
3111
3112const BAK_GENERATIONS: usize = 3;
3115
3116fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
3118 if gen == 0 {
3119 config_path.with_extension("json.bak")
3120 } else {
3121 config_path.with_extension(format!("json.bak.{gen}"))
3122 }
3123}
3124
3125fn rotate_backups(config_path: &std::path::Path, generations: usize) {
3131 for gen in (1..generations).rev() {
3132 let from = backup_path_for(config_path, gen - 1);
3133 let to = backup_path_for(config_path, gen);
3134 if from.exists() {
3135 if let Err(e) = std::fs::rename(&from, &to) {
3136 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
3137 }
3138 }
3139 }
3140}
3141
3142fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
3143 let Some(parent) = path.parent() else {
3144 return std::fs::write(path, content);
3145 };
3146
3147 std::fs::create_dir_all(parent)?;
3148
3149 let file_name = path
3152 .file_name()
3153 .and_then(|s| s.to_str())
3154 .unwrap_or("config.json");
3155 let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
3156 let tmp_path = parent.join(tmp_name);
3157
3158 {
3159 let mut file = std::fs::File::create(&tmp_path)?;
3160 file.write_all(content)?;
3161 file.sync_all()?;
3162 }
3163
3164 std::fs::rename(&tmp_path, path)?;
3165 Ok(())
3166}
3167
3168#[cfg(test)]
3169mod tests {
3170 use super::*;
3171 use std::ffi::OsString;
3172 use std::path::PathBuf;
3173 use std::sync::Mutex;
3174 use std::time::{SystemTime, UNIX_EPOCH};
3175
3176 struct EnvVarGuard {
3177 key: &'static str,
3178 previous: Option<OsString>,
3179 }
3180
3181 impl EnvVarGuard {
3182 fn set(key: &'static str, value: &str) -> Self {
3183 let previous = std::env::var_os(key);
3184 std::env::set_var(key, value);
3185 Self { key, previous }
3186 }
3187
3188 fn unset(key: &'static str) -> Self {
3189 let previous = std::env::var_os(key);
3190 std::env::remove_var(key);
3191 Self { key, previous }
3192 }
3193 }
3194
3195 impl Drop for EnvVarGuard {
3196 fn drop(&mut self) {
3197 match &self.previous {
3198 Some(value) => std::env::set_var(self.key, value),
3199 None => std::env::remove_var(self.key),
3200 }
3201 }
3202 }
3203
3204 #[test]
3205 fn subagents_config_without_remote_placements_deserializes_empty() {
3206 let json = r#"{ "max_concurrent": 4 }"#;
3209 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3210 assert_eq!(cfg.max_concurrent, Some(4));
3211 assert!(cfg.remote_placements.is_empty());
3212 let back = serde_json::to_string(&cfg).unwrap();
3214 assert!(
3215 !back.contains("remote_placements"),
3216 "empty vec is skipped: {back}"
3217 );
3218 }
3219
3220 #[test]
3221 fn remote_actor_placement_round_trips() {
3222 let json = r#"{
3223 "remote_placements": [
3224 {
3225 "role": "explorer",
3226 "endpoint": "wss://gpu-host:8443",
3227 "token_env": "WORKER_TOKEN",
3228 "ca_cert_file": "/etc/bamboo/worker.pem"
3229 },
3230 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
3231 ]
3232 }"#;
3233 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3234 assert_eq!(cfg.remote_placements.len(), 2);
3235 let p0 = &cfg.remote_placements[0];
3236 assert_eq!(p0.role, "explorer");
3237 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
3238 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
3239 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
3240 let p1 = &cfg.remote_placements[1];
3242 assert_eq!(p1.role, "writer");
3243 assert!(p1.token_env.is_none());
3244 assert!(p1.ca_cert_file.is_none());
3245
3246 let back = serde_json::to_string(&cfg).unwrap();
3247 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3248 assert_eq!(cfg, reparsed, "round-trip is stable");
3249 assert!(!back.contains("\"token_env\":null"));
3250 assert!(!back.contains("\"ca_cert_file\":null"));
3251 }
3252
3253 #[test]
3254 fn subagents_config_without_schedulable_placements_deserializes_empty() {
3255 let json = r#"{ "max_concurrent": 4 }"#;
3258 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3259 assert!(cfg.schedulable_placements.is_empty());
3260 let back = serde_json::to_string(&cfg).unwrap();
3262 assert!(
3263 !back.contains("schedulable_placements"),
3264 "empty vec is skipped: {back}"
3265 );
3266 }
3267
3268 #[test]
3269 fn schedulable_placement_round_trips() {
3270 let json = r#"{
3271 "schedulable_placements": [
3272 {
3273 "role": "explorer",
3274 "pool": "gpu-pool",
3275 "registry_url": "https://control-plane:9562",
3276 "token_env": "WORKER_TOKEN",
3277 "ca_cert_file": "/etc/bamboo/worker.pem"
3278 },
3279 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
3280 ]
3281 }"#;
3282 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3283 assert_eq!(cfg.schedulable_placements.len(), 2);
3284 let p0 = &cfg.schedulable_placements[0];
3285 assert_eq!(p0.role, "explorer");
3286 assert_eq!(p0.pool, "gpu-pool");
3287 assert_eq!(p0.registry_url, "https://control-plane:9562");
3288 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
3289 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
3290 let p1 = &cfg.schedulable_placements[1];
3292 assert_eq!(p1.role, "writer");
3293 assert_eq!(p1.pool, "cpu-pool");
3294 assert!(p1.token_env.is_none());
3295 assert!(p1.ca_cert_file.is_none());
3296
3297 let back = serde_json::to_string(&cfg).unwrap();
3298 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3299 assert_eq!(cfg, reparsed, "round-trip is stable");
3300 assert!(!back.contains("\"token_env\":null"));
3301 assert!(!back.contains("\"ca_cert_file\":null"));
3302 }
3303
3304 #[test]
3305 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
3306 let json = r#"{ "max_concurrent": 4 }"#;
3310 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3311 assert!(cfg.mcp_role_allowlist.is_empty());
3312 let back = serde_json::to_string(&cfg).unwrap();
3314 assert!(
3315 !back.contains("mcp_role_allowlist"),
3316 "empty vec is skipped: {back}"
3317 );
3318 }
3319
3320 #[test]
3321 fn mcp_role_allowlist_entry_round_trips() {
3322 let json = r#"{
3323 "mcp_role_allowlist": [
3324 { "role": "researcher", "tools": ["fetch_url"] },
3325 { "role": "sandboxed", "tools": [] }
3326 ]
3327 }"#;
3328 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3329 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
3330 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
3331 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
3332 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
3335 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
3336
3337 let back = serde_json::to_string(&cfg).unwrap();
3338 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3339 assert_eq!(cfg, reparsed, "round-trip is stable");
3340 }
3341
3342 #[test]
3343 fn server_config_without_tls_field_deserializes_back_compat() {
3344 let server: ServerConfig = serde_json::from_value(serde_json::json!({
3347 "port": 9562,
3348 "bind": "127.0.0.1"
3349 }))
3350 .expect("legacy server config without tls should deserialize");
3351
3352 assert_eq!(server.tls, None);
3353 assert_eq!(server.port, 9562);
3354 assert_eq!(server.bind, "127.0.0.1");
3355 }
3356
3357 #[test]
3358 fn server_config_omits_tls_when_none() {
3359 let server = ServerConfig::default();
3362 let value = serde_json::to_value(&server).expect("server config should serialize");
3363 let obj = value
3364 .as_object()
3365 .expect("server config serializes to object");
3366 assert!(
3367 !obj.contains_key("tls"),
3368 "tls must be omitted when None, got: {value}"
3369 );
3370 }
3371
3372 #[test]
3373 fn server_config_with_tls_roundtrips() {
3374 let server: ServerConfig = serde_json::from_value(serde_json::json!({
3375 "port": 9562,
3376 "bind": "0.0.0.0",
3377 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
3378 }))
3379 .expect("server config with tls should deserialize");
3380
3381 let tls = server.tls.clone().expect("tls should be Some");
3382 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
3383 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
3384
3385 let value = serde_json::to_value(&server).expect("serialize");
3387 assert!(value.as_object().unwrap().contains_key("tls"));
3388 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
3389 assert_eq!(back.tls, server.tls);
3390 }
3391
3392 #[test]
3393 fn access_control_without_devices_field_deserializes_back_compat() {
3394 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
3397 "password_enabled": true,
3398 "password_hash": "deadbeef",
3399 "password_salt": "01020304",
3400 }))
3401 .expect("legacy access_control without devices should deserialize");
3402
3403 assert!(access.devices.is_empty());
3404 assert!(access.password_enabled);
3405 }
3406
3407 #[test]
3408 fn access_control_omits_devices_when_empty() {
3409 let access = AccessControlConfig {
3412 password_enabled: true,
3413 password_hash: Some("deadbeef".to_string()),
3414 password_salt: Some("01020304".to_string()),
3415 updated_at: None,
3416 devices: Vec::new(),
3417 };
3418 let value = serde_json::to_value(&access).expect("serialize");
3419 let obj = value.as_object().expect("object");
3420 assert!(
3421 !obj.contains_key("devices"),
3422 "devices must be omitted when empty, got: {value}"
3423 );
3424 }
3425
3426 #[test]
3427 fn access_control_with_devices_roundtrips() {
3428 let device = DeviceCredential {
3429 device_id: "bamboo_0123456789ab".to_string(),
3430 label: "iPhone 15".to_string(),
3431 token_hash: "abcd".to_string(),
3432 token_salt: "ef01".to_string(),
3433 created_at: "2026-06-23T00:00:00Z".to_string(),
3434 last_used_at: None,
3435 revoked: false,
3436 };
3437 let access = AccessControlConfig {
3438 password_enabled: true,
3439 password_hash: Some("deadbeef".to_string()),
3440 password_salt: Some("01020304".to_string()),
3441 updated_at: None,
3442 devices: vec![device.clone()],
3443 };
3444 let value = serde_json::to_value(&access).expect("serialize");
3445 assert!(value.as_object().unwrap().contains_key("devices"));
3446 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
3447 assert_eq!(back.devices, vec![device]);
3448 }
3449
3450 #[test]
3451 fn reasoning_effort_for_key_resolves_instance_id() {
3452 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
3456 "provider_type": "copilot",
3457 "reasoning_effort": "high",
3458 }))
3459 .expect("instance config should deserialize");
3460
3461 let mut config = Config::create_default();
3462 config
3463 .provider_instances
3464 .insert("copilot-work".to_string(), instance);
3465
3466 assert_eq!(
3467 config.reasoning_effort_for_key("copilot-work"),
3468 Some(ReasoningEffort::High),
3469 );
3470 }
3471
3472 #[test]
3473 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
3474 let mut config = Config::create_default();
3476 config.providers.bodhi = Some(
3477 serde_json::from_value(serde_json::json!({
3478 "reasoning_effort": "xhigh",
3479 }))
3480 .expect("bodhi config should deserialize"),
3481 );
3482
3483 assert_eq!(
3484 config.reasoning_effort_for_key("bodhi"),
3485 Some(ReasoningEffort::Xhigh),
3486 );
3487 }
3488
3489 #[test]
3490 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
3491 let mut config = Config::create_default();
3492 config.providers.openai = Some(
3493 serde_json::from_value(serde_json::json!({
3494 "api_key": "sk-test",
3495 "reasoning_effort": "low",
3496 }))
3497 .expect("openai config should deserialize"),
3498 );
3499
3500 assert_eq!(
3501 config.reasoning_effort_for_key("openai"),
3502 Some(ReasoningEffort::Low),
3503 );
3504 }
3505
3506 #[test]
3507 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
3508 let config = Config::create_default();
3509 assert_eq!(config.reasoning_effort_for_key("nope"), None);
3510 assert_eq!(config.reasoning_effort_for_key(" "), None);
3511 }
3512
3513 struct TempHome {
3514 path: PathBuf,
3515 }
3516
3517 impl TempHome {
3518 fn new() -> Self {
3519 let nanos = SystemTime::now()
3520 .duration_since(UNIX_EPOCH)
3521 .expect("clock should be after unix epoch")
3522 .as_nanos();
3523 let path = std::env::temp_dir().join(format!(
3524 "chat-core-config-test-{}-{}",
3525 std::process::id(),
3526 nanos
3527 ));
3528 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
3529 Self { path }
3530 }
3531
3532 fn set_config_json(&self, content: &str) {
3533 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
3536 std::fs::write(self.path.join("config.json"), content)
3537 .expect("failed to write config.json");
3538 }
3539 }
3540
3541 impl Drop for TempHome {
3542 fn drop(&mut self) {
3543 let _ = std::fs::remove_dir_all(&self.path);
3544 }
3545 }
3546
3547 fn env_lock() -> &'static Mutex<()> {
3551 crate::test_support::env_cache_lock()
3552 }
3553
3554 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
3556 env_lock().lock().unwrap_or_else(|poisoned| {
3557 poisoned.into_inner()
3559 })
3560 }
3561
3562 #[test]
3563 fn parse_bool_env_true_values() {
3564 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
3565 assert!(parse_bool_env(value), "value {value:?} should be true");
3566 }
3567 }
3568
3569 #[test]
3570 fn parse_bool_env_false_values() {
3571 for value in ["0", "false", "no", "off", "", " "] {
3572 assert!(!parse_bool_env(value), "value {value:?} should be false");
3573 }
3574 }
3575
3576 #[test]
3577 fn config_new_ignores_http_proxy_env_vars() {
3578 let _lock = env_lock_acquire();
3579 let temp_home = TempHome::new();
3580 temp_home.set_config_json(
3581 r#"{
3582 "http_proxy": "",
3583 "https_proxy": ""
3584}"#,
3585 );
3586
3587 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
3588 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
3589
3590 let config = Config::from_data_dir(Some(temp_home.path.clone()));
3591
3592 assert!(
3593 config.http_proxy.is_empty(),
3594 "config should ignore HTTP_PROXY env var"
3595 );
3596 assert!(
3597 config.https_proxy.is_empty(),
3598 "config should ignore HTTPS_PROXY env var"
3599 );
3600 }
3601
3602 #[test]
3603 fn config_new_loads_config_when_proxy_fields_omitted() {
3604 let _lock = env_lock_acquire();
3605 let temp_home = TempHome::new();
3606 temp_home.set_config_json(
3607 r#"{
3608 "provider": "openai",
3609 "providers": {
3610 "openai": {
3611 "api_key": "sk-test",
3612 "model": "gpt-4o"
3613 }
3614 }
3615}"#,
3616 );
3617
3618 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
3619 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
3620
3621 let config = Config::from_data_dir(Some(temp_home.path.clone()));
3622
3623 assert_eq!(
3624 config
3625 .providers
3626 .openai
3627 .as_ref()
3628 .and_then(|c| c.model.as_deref()),
3629 Some("gpt-4o"),
3630 "config should load provider model from config file even when proxy fields are omitted"
3631 );
3632 assert!(config.http_proxy.is_empty());
3633 assert!(config.https_proxy.is_empty());
3634 }
3635
3636 #[test]
3637 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
3638 let _lock = crate::test_support::env_cache_lock_acquire();
3639 let config = Config {
3640 env_vars: vec![
3641 EnvVarEntry {
3642 name: "SECRET_TOKEN".to_string(),
3643 value: "top-secret".to_string(),
3644 secret: true,
3645 value_encrypted: None,
3646 description: Some("Service token".to_string()),
3647 },
3648 EnvVarEntry {
3649 name: "API_BASE".to_string(),
3650 value: "https://internal.example".to_string(),
3651 secret: false,
3652 value_encrypted: None,
3653 description: Some("Internal API base".to_string()),
3654 },
3655 ],
3656 ..Default::default()
3657 };
3658
3659 config.publish_env_vars();
3660
3661 let injected = Config::current_env_vars();
3662 assert_eq!(
3663 injected.get("SECRET_TOKEN").map(String::as_str),
3664 Some("top-secret")
3665 );
3666 assert_eq!(
3667 injected.get("API_BASE").map(String::as_str),
3668 Some("https://internal.example")
3669 );
3670
3671 let prompt_safe = Config::current_prompt_safe_env_vars();
3672 assert_eq!(prompt_safe.len(), 2);
3673 assert!(prompt_safe.iter().any(|entry| {
3674 entry.name == "SECRET_TOKEN"
3675 && entry.secret
3676 && entry.description.as_deref() == Some("Service token")
3677 }));
3678 assert!(prompt_safe.iter().any(|entry| {
3679 entry.name == "API_BASE"
3680 && !entry.secret
3681 && entry.description.as_deref() == Some("Internal API base")
3682 }));
3683 assert!(!prompt_safe
3684 .iter()
3685 .any(|entry| entry.name.contains("top-secret")));
3686 assert!(!prompt_safe.iter().any(|entry| {
3687 entry
3688 .description
3689 .as_deref()
3690 .is_some_and(|value| value.contains("https://internal.example"))
3691 }));
3692 }
3693
3694 #[test]
3695 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
3696 let _lock = crate::test_support::env_cache_lock_acquire();
3697
3698 Config {
3700 env_vars: vec![EnvVarEntry {
3701 name: "BAMBOO_CACHE_OWNER_40".to_string(),
3702 value: "live".to_string(),
3703 secret: false,
3704 value_encrypted: None,
3705 description: None,
3706 }],
3707 ..Default::default()
3708 }
3709 .publish_env_vars();
3710 assert_eq!(
3711 Config::current_env_vars()
3712 .get("BAMBOO_CACHE_OWNER_40")
3713 .map(String::as_str),
3714 Some("live")
3715 );
3716
3717 let temp = TempHome::new();
3719 temp.set_config_json(
3720 &serde_json::json!({
3721 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
3722 })
3723 .to_string(),
3724 );
3725
3726 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3729 assert_eq!(
3730 loaded
3731 .env_vars
3732 .iter()
3733 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
3734 .map(|e| e.value.as_str()),
3735 Some("stale-disk"),
3736 "the returned Config holds the disk value"
3737 );
3738 assert_eq!(
3739 Config::current_env_vars()
3740 .get("BAMBOO_CACHE_OWNER_40")
3741 .map(String::as_str),
3742 Some("live"),
3743 "but the global cache is UNTOUCHED — no clobber (#40)"
3744 );
3745
3746 let _ = Config::from_data_dir(Some(temp.path.clone()));
3748 assert_eq!(
3749 Config::current_env_vars()
3750 .get("BAMBOO_CACHE_OWNER_40")
3751 .map(String::as_str),
3752 Some("stale-disk"),
3753 "the publishing loader clobbers the cache (contrast)"
3754 );
3755 }
3756
3757 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
3758 std::fs::read_dir(dir)
3759 .unwrap()
3760 .filter_map(|e| e.ok())
3761 .any(|e| {
3762 e.file_name()
3763 .to_string_lossy()
3764 .contains("config.json.corrupted.")
3765 })
3766 }
3767
3768 #[test]
3769 fn corrupt_config_recovered_from_backup_and_quarantined() {
3770 let temp = TempHome::new();
3771 std::fs::write(
3773 temp.path.join("config.json.bak"),
3774 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
3775 )
3776 .unwrap();
3777 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
3779
3780 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3781 assert_eq!(
3782 config.http_proxy, "http://from-backup",
3783 "recovered from config.json.bak instead of losing all config"
3784 );
3785 assert!(
3786 dir_has_quarantine_file(&temp.path),
3787 "corrupt config.json was quarantined (preserved), not discarded"
3788 );
3789 }
3790
3791 #[test]
3792 fn corrupt_config_without_backup_quarantines_then_defaults() {
3793 let temp = TempHome::new();
3794 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
3795
3796 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3797 assert!(
3798 config.http_proxy.is_empty(),
3799 "no backup -> falls back to defaults"
3800 );
3801 assert!(
3802 dir_has_quarantine_file(&temp.path),
3803 "corrupt config.json is quarantined even when there's no backup"
3804 );
3805 }
3806
3807 #[test]
3808 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
3809 let temp = TempHome::new();
3810 temp.set_config_json(
3815 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
3816 );
3817
3818 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3819 assert_eq!(
3820 config.http_proxy, "http://salvaged",
3821 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
3822 );
3823 assert!(
3824 config.env_vars.is_empty(),
3825 "the corrupt field reset to its default instead of failing the whole load"
3826 );
3827 assert!(
3828 dir_has_quarantine_file(&temp.path),
3829 "the corrupt config.json was still quarantined for inspection"
3830 );
3831 }
3832
3833 #[test]
3834 fn salvage_preferred_over_backup_for_most_recent_intent() {
3835 let temp = TempHome::new();
3836 std::fs::write(
3838 temp.path.join("config.json.bak"),
3839 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
3840 )
3841 .unwrap();
3842 temp.set_config_json(
3844 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
3845 );
3846
3847 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3848 assert_eq!(
3849 config.http_proxy, "http://new-salvaged",
3850 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
3851 );
3852 }
3853
3854 #[test]
3855 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
3856 let temp = TempHome::new();
3857 std::fs::write(
3859 temp.path.join("config.json.bak"),
3860 serde_json::json!({
3861 "http_proxy": "http://old-from-backup",
3862 "https_proxy": "https://kept-from-backup",
3863 })
3864 .to_string(),
3865 )
3866 .unwrap();
3867 temp.set_config_json(
3870 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
3871 );
3872
3873 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3874 assert_eq!(
3876 config.http_proxy, "http://newer-edit",
3877 "the corrupt file's newer valid edit is applied"
3878 );
3879 assert_eq!(
3881 config.https_proxy, "https://kept-from-backup",
3882 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
3883 );
3884 }
3885
3886 #[test]
3887 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
3888 let temp = TempHome::new();
3889 std::fs::write(
3892 temp.path.join("config.json.bak"),
3893 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
3894 )
3895 .unwrap();
3896 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
3897
3898 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3899 assert_eq!(
3900 config.http_proxy, "http://from-backup",
3901 "garbage (non-object) config skips salvage and recovers from .bak"
3902 );
3903 }
3904
3905 #[test]
3906 fn quarantine_files_are_capped_to_newest_n() {
3907 let temp = TempHome::new();
3908 let config_path = temp.path.join("config.json");
3909 std::fs::write(&config_path, "{}").unwrap();
3910
3911 for _ in 0..(QUARANTINE_KEEP + 3) {
3914 quarantine_corrupt_config(&config_path);
3915 std::thread::sleep(std::time::Duration::from_millis(3));
3916 }
3917
3918 let count = std::fs::read_dir(&temp.path)
3919 .unwrap()
3920 .filter_map(|e| e.ok())
3921 .filter(|e| {
3922 e.file_name()
3923 .to_string_lossy()
3924 .starts_with("config.json.corrupted.")
3925 })
3926 .count();
3927 assert_eq!(
3928 count, QUARANTINE_KEEP,
3929 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
3930 );
3931 }
3932
3933 #[test]
3934 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
3935 let temp = TempHome::new();
3936 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
3938 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
3939 std::fs::write(
3940 temp.path.join("config.json.bak.1"),
3941 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
3942 )
3943 .unwrap();
3944
3945 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
3946 assert_eq!(
3947 config.http_proxy, "http://from-gen-1",
3948 "recovered from .bak.1 when both config.json and .bak are corrupt"
3949 );
3950 }
3951
3952 #[test]
3953 fn save_rotates_backup_generations() {
3954 let temp = TempHome::new();
3955 let path = temp.path.join("config.json");
3956 std::fs::write(
3958 &path,
3959 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
3960 )
3961 .unwrap();
3962
3963 let mut cfg = Config::create_default();
3964 cfg.http_proxy = "http://proxy-v2".to_string();
3966 cfg.save_to_dir(temp.path.clone()).unwrap();
3967 cfg.http_proxy = "http://proxy-v3".to_string();
3969 cfg.save_to_dir(temp.path.clone()).unwrap();
3970
3971 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
3972 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
3973 assert!(
3974 bak.contains("proxy-v2"),
3975 ".bak holds the previous generation (v2)"
3976 );
3977 assert!(
3978 bak1.contains("proxy-v1"),
3979 ".bak.1 holds the older rotated generation (v1)"
3980 );
3981 }
3982
3983 #[test]
3984 fn save_backs_up_existing_config() {
3985 let temp = TempHome::new();
3986 std::fs::write(
3988 temp.path.join("config.json"),
3989 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
3990 )
3991 .unwrap();
3992
3993 let mut config = Config::create_default();
3994 config.http_proxy = "http://new".to_string();
3995 config
3996 .save_to_dir(temp.path.clone())
3997 .expect("save succeeds");
3998
3999 let backup =
4000 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
4001 assert!(
4002 backup.contains("http://old"),
4003 "config.json.bak holds the PREVIOUS config (last-known-good)"
4004 );
4005 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
4006 assert!(
4007 current.contains("http://new"),
4008 "config.json holds the new config"
4009 );
4010 }
4011
4012 #[test]
4013 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
4014 let temp = TempHome::new();
4015 std::fs::write(
4017 temp.path.join("config.json.bak"),
4018 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
4019 )
4020 .unwrap();
4021 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
4024
4025 let mut config = Config::create_default();
4026 config.http_proxy = "http://new".to_string();
4027 config
4028 .save_to_dir(temp.path.clone())
4029 .expect("save succeeds");
4030
4031 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
4033 assert!(
4034 backup.contains("http://good-bak"),
4035 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
4036 );
4037 }
4038
4039 fn connect_platform_with_encrypted(
4042 platform_type: &str,
4043 token_encrypted: &str,
4044 ) -> ConnectPlatformConfig {
4045 ConnectPlatformConfig {
4046 platform_type: platform_type.to_string(),
4047 token: None,
4048 token_encrypted: Some(token_encrypted.to_string()),
4049 app_id: None,
4050 app_secret: None,
4051 app_secret_encrypted: None,
4052 domain: None,
4053 allow_from: vec!["user-1".to_string()],
4054 admin_from: Vec::new(),
4055 }
4056 }
4057
4058 fn connect_json_path(temp: &TempHome) -> PathBuf {
4059 temp.path.join("connect.json")
4060 }
4061
4062 #[test]
4063 fn save_splits_connect_into_sibling_connect_json() {
4064 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4065 let temp = TempHome::new();
4066
4067 let mut config = Config::create_default();
4068 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
4069 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
4070
4071 config
4072 .save_to_dir(temp.path.clone())
4073 .expect("save succeeds");
4074
4075 let config_json: serde_json::Value =
4076 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
4077 .unwrap();
4078 assert!(
4079 config_json.get("connect").is_none(),
4080 "config.json must not carry the `connect` key after a save"
4081 );
4082
4083 let connect_json: serde_json::Value =
4084 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4085 .unwrap();
4086 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
4087 assert!(
4088 connect_json["platforms"][0]["token_encrypted"]
4089 .as_str()
4090 .is_some_and(|v| !v.is_empty()),
4091 "the token is persisted in its encrypted form in connect.json"
4092 );
4093 assert!(
4094 connect_json["platforms"][0].get("token").is_none(),
4095 "the plaintext token is never persisted (skip_serializing)"
4096 );
4097 }
4098
4099 #[test]
4100 fn load_merges_connect_json_into_config() {
4101 let temp = TempHome::new();
4102 std::fs::write(
4103 connect_json_path(&temp),
4104 serde_json::json!({
4105 "platforms": [
4106 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
4107 ]
4108 })
4109 .to_string(),
4110 )
4111 .unwrap();
4112
4113 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4114 assert_eq!(config.connect.platforms.len(), 1);
4115 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
4116 assert_eq!(
4117 config.connect.platforms[0].token_encrypted.as_deref(),
4118 Some("cipher-abc")
4119 );
4120 }
4121
4122 #[test]
4123 fn load_without_connect_json_yields_empty_inert_connect_config() {
4124 let temp = TempHome::new();
4125 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
4126
4127 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4128 assert!(
4129 config.connect.platforms.is_empty(),
4130 "no connect.json and no legacy key -> empty/inert connect config"
4131 );
4132 assert!(
4133 !connect_json_path(&temp).exists(),
4134 "load must not create connect.json when there is nothing to migrate"
4135 );
4136 }
4137
4138 #[test]
4139 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
4140 let temp = TempHome::new();
4141 temp.set_config_json(
4143 &serde_json::json!({
4144 "http_proxy": "http://keep-me",
4145 "connect": {
4146 "platforms": [
4147 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
4148 ]
4149 }
4150 })
4151 .to_string(),
4152 );
4153
4154 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4155
4156 assert_eq!(config.connect.platforms.len(), 1);
4158 assert_eq!(
4159 config.connect.platforms[0].token_encrypted.as_deref(),
4160 Some("legacy-cipher")
4161 );
4162 assert_eq!(config.http_proxy, "http://keep-me");
4164
4165 assert!(
4167 connect_json_path(&temp).exists(),
4168 "migration proactively creates connect.json"
4169 );
4170 let connect_json: serde_json::Value =
4171 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4172 .unwrap();
4173 assert_eq!(
4174 connect_json["platforms"][0]["token_encrypted"], "legacy-cipher",
4175 "the encrypted value is preserved (encrypted form intact) by the migration"
4176 );
4177
4178 let config_json: serde_json::Value =
4180 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
4181 .unwrap();
4182 assert!(
4183 config_json.get("connect").is_none(),
4184 "config.json is rewritten without the legacy `connect` key"
4185 );
4186 }
4187
4188 #[test]
4196 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
4197 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
4198 let temp = TempHome::new();
4199
4200 let original_api_key_encrypted =
4201 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
4202 temp.set_config_json(
4203 &serde_json::json!({
4204 "providers": {
4205 "openai": {
4206 "api_key_encrypted": original_api_key_encrypted,
4207 }
4208 },
4209 "connect": {
4210 "platforms": [
4211 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
4212 ]
4213 }
4214 })
4215 .to_string(),
4216 );
4217
4218 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4219 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
4220
4221 assert!(
4224 !temp.path.join("config.json.bak").exists(),
4225 "a read-only load migrating a legacy `connect` key must not rotate \
4226 config.json backups"
4227 );
4228
4229 let config_json: serde_json::Value =
4234 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
4235 .unwrap();
4236 assert_eq!(
4237 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
4238 "an unrelated secret's ciphertext must not be touched by the connect \
4239 migration's narrow write"
4240 );
4241 assert!(
4242 config_json.get("connect").is_none(),
4243 "config.json is still rewritten without the legacy `connect` key"
4244 );
4245 }
4246
4247 #[test]
4248 fn both_files_present_connect_json_wins() {
4249 let temp = TempHome::new();
4250 temp.set_config_json(
4251 &serde_json::json!({
4252 "connect": {
4253 "platforms": [
4254 { "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
4255 ]
4256 }
4257 })
4258 .to_string(),
4259 );
4260 std::fs::write(
4261 connect_json_path(&temp),
4262 serde_json::json!({
4263 "platforms": [
4264 { "type": "telegram", "token_encrypted": "authoritative-cipher" }
4265 ]
4266 })
4267 .to_string(),
4268 )
4269 .unwrap();
4270
4271 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4272 assert_eq!(
4273 config.connect.platforms[0].token_encrypted.as_deref(),
4274 Some("authoritative-cipher"),
4275 "connect.json wins over a stale legacy config.json key"
4276 );
4277 }
4278
4279 #[test]
4284 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
4285 let temp = TempHome::new();
4286 temp.set_config_json(
4287 &serde_json::json!({
4288 "http_proxy": "http://keep-me",
4289 "connect": {
4290 "platforms": [
4291 { "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
4292 ]
4293 }
4294 })
4295 .to_string(),
4296 );
4297 std::fs::write(
4298 connect_json_path(&temp),
4299 serde_json::json!({
4300 "platforms": [
4301 { "type": "telegram", "token_encrypted": "authoritative-cipher" }
4302 ]
4303 })
4304 .to_string(),
4305 )
4306 .unwrap();
4307
4308 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4309 assert_eq!(
4310 config.connect.platforms[0].token_encrypted.as_deref(),
4311 Some("authoritative-cipher")
4312 );
4313 assert_eq!(config.http_proxy, "http://keep-me");
4315
4316 let config_json: serde_json::Value =
4317 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
4318 .unwrap();
4319 assert!(
4320 config_json.get("connect").is_none(),
4321 "the stale legacy `connect` key must be stripped from config.json \
4322 immediately on load, not left for the next natural save"
4323 );
4324 }
4325
4326 #[test]
4327 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
4328 let temp = TempHome::new();
4329 temp.set_config_json("{}");
4330 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
4331
4332 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4333 assert!(
4334 config.connect.platforms.is_empty(),
4335 "corrupt connect.json fails SAFE to an empty/inert connect config"
4336 );
4337
4338 let backup = connect_json_path(&temp).with_extension("json.bak");
4339 assert!(
4340 backup.exists(),
4341 "the corrupt connect.json is quarantined to connect.json.bak"
4342 );
4343 assert!(
4344 std::fs::read_to_string(backup)
4345 .unwrap()
4346 .contains("not valid json"),
4347 "the quarantined copy holds the bad content"
4348 );
4349 assert!(
4354 !connect_json_path(&temp).exists(),
4355 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
4356 connect.json should remain after quarantine"
4357 );
4358 }
4359
4360 #[test]
4361 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
4362 let temp = TempHome::new();
4363 temp.set_config_json(
4366 &serde_json::json!({
4367 "connect": {
4368 "platforms": [
4369 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
4370 ]
4371 }
4372 })
4373 .to_string(),
4374 );
4375 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
4376
4377 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4378 assert!(
4379 config.connect.platforms.is_empty(),
4380 "corrupt connect.json must not fall back to the legacy config.json copy"
4381 );
4382 }
4383
4384 #[test]
4385 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
4386 let temp = TempHome::new();
4387 let config = Config::create_default();
4388 assert!(config.connect.platforms.is_empty());
4389
4390 config
4391 .save_to_dir(temp.path.clone())
4392 .expect("save succeeds");
4393
4394 assert!(
4395 !connect_json_path(&temp).exists(),
4396 "an empty connect config with no pre-existing file must not create one"
4397 );
4398 }
4399
4400 #[test]
4401 fn connect_json_backed_up_before_overwrite() {
4402 let temp = TempHome::new();
4403 std::fs::write(
4404 connect_json_path(&temp),
4405 serde_json::json!({
4406 "platforms": [
4407 { "type": "telegram", "token_encrypted": "old-cipher" }
4408 ]
4409 })
4410 .to_string(),
4411 )
4412 .unwrap();
4413
4414 let mut config = Config::create_default();
4415 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
4416 config
4417 .save_to_dir(temp.path.clone())
4418 .expect("save succeeds");
4419
4420 let backup = connect_json_path(&temp).with_extension("json.bak");
4421 assert!(
4422 std::fs::read_to_string(backup)
4423 .unwrap()
4424 .contains("old-cipher"),
4425 "the previous connect.json is preserved as connect.json.bak before the overwrite"
4426 );
4427 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
4428 assert!(current.contains("new-cipher"));
4429 }
4430
4431 #[test]
4434 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
4435 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4436 let temp = TempHome::new();
4437
4438 let mut config = Config::create_default();
4439 config.connect.platforms = vec![ConnectPlatformConfig {
4440 platform_type: "feishu".to_string(),
4441 token: None,
4442 token_encrypted: None,
4443 app_id: Some("cli_real_app_id".to_string()),
4444 app_secret: Some("plain-app-secret".to_string()),
4445 app_secret_encrypted: None,
4446 domain: Some("lark".to_string()),
4447 allow_from: vec!["ou_1".to_string()],
4448 admin_from: Vec::new(),
4449 }];
4450
4451 config
4452 .save_to_dir(temp.path.clone())
4453 .expect("save succeeds");
4454
4455 let connect_json: serde_json::Value =
4456 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4457 .unwrap();
4458 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
4459 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
4460 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
4461 assert!(
4462 connect_json["platforms"][0]["app_secret_encrypted"]
4463 .as_str()
4464 .is_some_and(|v| !v.is_empty()),
4465 "app_secret is persisted in its encrypted form in connect.json"
4466 );
4467 assert!(
4468 connect_json["platforms"][0].get("app_secret").is_none(),
4469 "the plaintext app_secret is never persisted (skip_serializing)"
4470 );
4471 }
4472
4473 #[test]
4474 fn load_hydrates_feishu_app_secret_from_encrypted() {
4475 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4476 let temp = TempHome::new();
4477
4478 let mut config = Config::create_default();
4479 config.connect.platforms = vec![ConnectPlatformConfig {
4480 platform_type: "feishu".to_string(),
4481 token: None,
4482 token_encrypted: None,
4483 app_id: Some("cli_real_app_id".to_string()),
4484 app_secret: Some("plain-app-secret".to_string()),
4485 app_secret_encrypted: None,
4486 domain: Some("lark".to_string()),
4487 allow_from: vec!["ou_1".to_string()],
4488 admin_from: Vec::new(),
4489 }];
4490 config
4491 .save_to_dir(temp.path.clone())
4492 .expect("save succeeds");
4493
4494 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4495 assert_eq!(reloaded.connect.platforms.len(), 1);
4496 assert_eq!(
4497 reloaded.connect.platforms[0].app_secret.as_deref(),
4498 Some("plain-app-secret"),
4499 "reload hydrates app_secret from app_secret_encrypted"
4500 );
4501 assert_eq!(
4502 reloaded.connect.platforms[0].app_id.as_deref(),
4503 Some("cli_real_app_id")
4504 );
4505 assert_eq!(
4506 reloaded.connect.platforms[0].domain.as_deref(),
4507 Some("lark")
4508 );
4509 }
4510
4511 #[test]
4512 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
4513 let temp = TempHome::new();
4514 std::fs::write(
4515 connect_json_path(&temp),
4516 serde_json::json!({
4517 "platforms": [
4518 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
4519 ]
4520 })
4521 .to_string(),
4522 )
4523 .unwrap();
4524
4525 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4526
4527 assert_eq!(config.connect.platforms.len(), 1);
4528 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
4529 assert_eq!(
4530 config.connect.platforms[0].token_encrypted.as_deref(),
4531 Some("legacy-cipher")
4532 );
4533 assert_eq!(
4534 config.connect.platforms[0].app_id, None,
4535 "a legacy entry with no Feishu fields deserializes them as None"
4536 );
4537 assert_eq!(config.connect.platforms[0].app_secret, None);
4538 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
4539 assert_eq!(config.connect.platforms[0].domain, None);
4540 }
4541
4542 #[test]
4543 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
4544 let _lock = env_lock_acquire();
4545 let temp_home = TempHome::new();
4546 temp_home.set_config_json(
4547 r#"{
4548 "provider": "openai",
4549 "providers": {
4550 "openai": {
4551 "api_key": "sk-test",
4552 "model": "gpt-4o"
4553 }
4554 }
4555}"#,
4556 );
4557
4558 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
4559 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
4560
4561 let config = Config::from_data_dir(Some(temp_home.path.clone()));
4562
4563 assert_eq!(
4564 config
4565 .providers
4566 .openai
4567 .as_ref()
4568 .and_then(|c| c.model.as_deref()),
4569 Some("gpt-4o")
4570 );
4571 assert!(
4572 config.http_proxy.is_empty(),
4573 "config should keep http_proxy empty when field is omitted"
4574 );
4575 assert!(
4576 config.https_proxy.is_empty(),
4577 "config should keep https_proxy empty when field is omitted"
4578 );
4579 }
4580
4581 #[test]
4582 fn get_memory_background_model_prefers_memory_specific_override() {
4583 let mut config = Config::default();
4584 config.features.provider_model_ref = false;
4585 config.provider = "openai".to_string();
4586 config.providers.openai = Some(OpenAIConfig {
4587 api_key: "test".to_string(),
4588 api_key_encrypted: None,
4589 base_url: None,
4590 model: Some("gpt-main".to_string()),
4591 fast_model: Some("gpt-fast".to_string()),
4592 vision_model: None,
4593 reasoning_effort: None,
4594 responses_only_models: vec![],
4595 request_overrides: None,
4596 extra: BTreeMap::new(),
4597 api_key_from_env: false,
4598 });
4599 config.memory = Some(MemoryConfig {
4600 background_model: Some("memory-fast".to_string()),
4601 ..MemoryConfig::default()
4602 });
4603
4604 assert_eq!(
4605 config.get_memory_background_model().as_deref(),
4606 Some("memory-fast")
4607 );
4608 }
4609
4610 #[test]
4611 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
4612 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
4617 api_key: api_key.to_string(),
4618 api_key_encrypted: None,
4619 base_url: None,
4620 model: None,
4621 fast_model: None,
4622 vision_model: None,
4623 reasoning_effort: None,
4624 responses_only_models: vec![],
4625 request_overrides: None,
4626 extra: BTreeMap::new(),
4627 api_key_from_env: from_env,
4628 };
4629
4630 let mut current = Config::default();
4632 current.providers.openai = Some(openai("sk-env", true));
4633 let mut merged = Config::default();
4634 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
4636 let got = merged.providers.openai.as_ref().unwrap();
4637 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
4638 assert!(got.api_key_from_env, "env flag restored");
4639
4640 let mut merged = Config::default();
4642 merged.providers.openai = Some(openai("sk-explicit", false));
4643 merged.preserve_env_sourced_provider_keys(¤t);
4644 assert_eq!(
4645 merged.providers.openai.as_ref().unwrap().api_key,
4646 "sk-explicit",
4647 "explicit patch key must win"
4648 );
4649
4650 let mut current_plain = Config::default();
4652 current_plain.providers.openai = Some(openai("sk-plain", false));
4653 let mut merged = Config::default();
4654 merged.providers.openai = Some(openai("", false));
4655 merged.preserve_env_sourced_provider_keys(¤t_plain);
4656 assert!(
4657 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
4658 "non-env key must not be restored by this path"
4659 );
4660 }
4661
4662 #[test]
4663 fn refresh_preserves_ciphertext_when_plaintext_empty() {
4664 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
4668 api_key: api_key.to_string(),
4669 api_key_encrypted: enc.map(str::to_string),
4670 base_url: None,
4671 model: None,
4672 fast_model: None,
4673 vision_model: None,
4674 reasoning_effort: None,
4675 responses_only_models: vec![],
4676 request_overrides: None,
4677 extra: BTreeMap::new(),
4678 api_key_from_env: false,
4679 };
4680
4681 let mut config = Config::default();
4683 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
4684 config
4685 .refresh_provider_api_keys_encrypted()
4686 .expect("refresh");
4687 assert_eq!(
4688 config
4689 .providers
4690 .openai
4691 .as_ref()
4692 .unwrap()
4693 .api_key_encrypted
4694 .as_deref(),
4695 Some("preexisting-ciphertext"),
4696 "existing ciphertext must be preserved when plaintext is empty"
4697 );
4698
4699 let mut config = Config::default();
4701 config.providers.openai = Some(openai("", None));
4702 config
4703 .refresh_provider_api_keys_encrypted()
4704 .expect("refresh");
4705 assert!(
4706 config
4707 .providers
4708 .openai
4709 .as_ref()
4710 .unwrap()
4711 .api_key_encrypted
4712 .is_none(),
4713 "no key + no ciphertext should stay None"
4714 );
4715
4716 let mut config = Config::default();
4718 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
4719 config
4720 .refresh_provider_api_keys_encrypted()
4721 .expect("refresh");
4722 let enc = config
4723 .providers
4724 .openai
4725 .as_ref()
4726 .unwrap()
4727 .api_key_encrypted
4728 .clone()
4729 .expect("ciphertext present");
4730 assert!(
4731 !enc.is_empty() && enc != "stale-ciphertext",
4732 "plaintext re-encrypted"
4733 );
4734 }
4735
4736 #[test]
4737 fn get_memory_background_model_falls_back_to_provider_fast_model() {
4738 let mut config = Config::default();
4739 config.features.provider_model_ref = false;
4740 config.provider = "openai".to_string();
4741 config.providers.openai = Some(OpenAIConfig {
4742 api_key: "test".to_string(),
4743 api_key_encrypted: None,
4744 base_url: None,
4745 model: Some("gpt-main".to_string()),
4746 fast_model: Some("gpt-fast".to_string()),
4747 vision_model: None,
4748 reasoning_effort: None,
4749 responses_only_models: vec![],
4750 request_overrides: None,
4751 extra: BTreeMap::new(),
4752 api_key_from_env: false,
4753 });
4754
4755 assert_eq!(
4756 config.get_memory_background_model().as_deref(),
4757 Some("gpt-fast")
4758 );
4759 }
4760
4761 #[test]
4762 fn get_memory_background_model_does_not_fall_back_to_main_model() {
4763 let mut config = Config::default();
4764 config.features.provider_model_ref = false;
4765 config.provider = "openai".to_string();
4766 config.providers.openai = Some(OpenAIConfig {
4767 api_key: "test".to_string(),
4768 api_key_encrypted: None,
4769 base_url: None,
4770 model: Some("gpt-main".to_string()),
4771 fast_model: None,
4772 vision_model: None,
4773 reasoning_effort: None,
4774 responses_only_models: vec![],
4775 request_overrides: None,
4776 extra: BTreeMap::new(),
4777 api_key_from_env: false,
4778 });
4779
4780 assert!(config.get_memory_background_model().is_none());
4781 }
4782
4783 #[test]
4784 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
4785 let config = Config {
4786 memory: Some(MemoryConfig {
4787 background_model: Some("dream-fast".to_string()),
4788 auto_dream_enabled: true,
4789 auto_dream_interval_secs: 900,
4790 project_prompt_injection: false,
4791 relevant_recall: false,
4792 relevant_recall_rerank: true,
4793 project_first_dream: false,
4794 ledger_agenda_injection: false,
4795 ledger_gardener_enabled: false,
4796 ledger_gardener_interval_secs: 7_200,
4797 ledger_distillation_enabled: false,
4798 dream_refine_mode: true,
4799 gardener_enabled: true,
4800 gardener_interval_secs: 3_600,
4801 gardener_volume_trigger: 40,
4802 gardener_max_splits_per_run: 4,
4803 gardener_min_sections: 7,
4804 dedup_gardener_enabled: true,
4805 dedup_gardener_min_score: 0.7,
4806 dedup_gardener_max_merges_per_run: 3,
4807 memory_active_capacity: 500,
4808 capacity_max_archivals_per_run: 10,
4809 }),
4810 ..Config::default()
4811 };
4812
4813 let serialized = serde_json::to_string(&config).expect("config should serialize");
4814 let roundtrip: Config =
4815 serde_json::from_str(&serialized).expect("config should deserialize");
4816 let memory = roundtrip.memory.expect("memory config should exist");
4817 assert!(memory.auto_dream_enabled);
4818 assert!(!memory.project_prompt_injection);
4819 assert!(!memory.relevant_recall);
4820 assert!(memory.relevant_recall_rerank);
4821 assert!(!memory.project_first_dream);
4822 assert!(memory.dream_refine_mode);
4823 assert!(memory.gardener_enabled);
4824 assert_eq!(memory.gardener_interval_secs, 3_600);
4825 assert_eq!(memory.gardener_volume_trigger, 40);
4826 assert_eq!(memory.gardener_max_splits_per_run, 4);
4827 assert_eq!(memory.gardener_min_sections, 7);
4828 assert!(memory.dedup_gardener_enabled);
4829 assert_eq!(memory.dedup_gardener_min_score, 0.7);
4830 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
4831 assert_eq!(memory.memory_active_capacity, 500);
4832 assert_eq!(memory.capacity_max_archivals_per_run, 10);
4833 }
4834
4835 #[test]
4837 fn memory_active_capacity_defaults_off() {
4838 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
4839 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
4840 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
4841 let memory = parsed.memory.unwrap();
4842 assert_eq!(memory.memory_active_capacity, 0);
4843 assert_eq!(
4844 memory.capacity_max_archivals_per_run, 50,
4845 "omitted field takes the serde default fn"
4846 );
4847 }
4848
4849 #[test]
4853 fn memory_maintenance_integrators_default_on() {
4854 let defaults = MemoryConfig::default();
4855 assert!(defaults.auto_dream_enabled);
4856 assert!(defaults.gardener_enabled);
4857 assert!(defaults.dedup_gardener_enabled);
4858 assert_eq!(defaults.gardener_volume_trigger, 25);
4859
4860 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
4862 let memory = parsed.memory.expect("memory present");
4863 assert!(
4864 memory.auto_dream_enabled,
4865 "auto_dream on when field omitted"
4866 );
4867 assert!(memory.gardener_enabled, "gardener on when field omitted");
4868 assert!(
4869 memory.dedup_gardener_enabled,
4870 "dedup gardener on when field omitted"
4871 );
4872 let opted_out: Config =
4874 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
4875 assert!(!opted_out.memory.unwrap().gardener_enabled);
4876 }
4877
4878 #[test]
4879 fn memory_config_env_overrides_prompt_flags() {
4880 let _lock = env_lock_acquire();
4881 let temp_home = TempHome::new();
4882 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
4883 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
4884 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
4885 let _relevant_recall_rerank =
4886 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
4887 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
4888
4889 let config = Config::from_data_dir(Some(temp_home.path.clone()));
4890 let memory = config
4891 .memory
4892 .expect("memory config should be created by env overrides");
4893 assert!(!memory.project_prompt_injection);
4894 assert!(!memory.relevant_recall);
4895 assert!(memory.relevant_recall_rerank);
4896 assert!(!memory.project_first_dream);
4897 }
4898
4899 #[test]
4900 fn provider_api_keys_injected_from_env_and_never_persisted() {
4901 let _lock = env_lock_acquire();
4902 let temp_home = TempHome::new();
4903 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
4904 let _anthropic = EnvVarGuard::set("BAMBOO_ANTHROPIC_API_KEY", "sk-ant-from-env");
4905 let _openai = EnvVarGuard::set("BAMBOO_OPENAI_API_KEY", "sk-oai-from-env");
4906
4907 let config = Config::from_data_dir(Some(temp_home.path.clone()));
4910 assert_eq!(
4911 config
4912 .providers
4913 .anthropic
4914 .as_ref()
4915 .expect("anthropic created from env")
4916 .api_key,
4917 "sk-ant-from-env"
4918 );
4919 assert_eq!(
4920 config
4921 .providers
4922 .openai
4923 .as_ref()
4924 .expect("openai created from env")
4925 .api_key,
4926 "sk-oai-from-env"
4927 );
4928 assert!(config.providers.gemini.is_none());
4930
4931 config
4937 .save_to_dir(temp_home.path.clone())
4938 .expect("save config");
4939 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
4940 .expect("read persisted config.json");
4941 assert!(
4942 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
4943 "env key must not be persisted as plaintext"
4944 );
4945 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
4946 assert!(
4947 disk_json["providers"]["anthropic"]
4948 .get("api_key_encrypted")
4949 .is_none(),
4950 "env-sourced anthropic key must not be re-encrypted into config.json"
4951 );
4952 assert!(
4953 disk_json["providers"]["openai"]
4954 .get("api_key_encrypted")
4955 .is_none(),
4956 "env-sourced openai key must not be re-encrypted into config.json"
4957 );
4958
4959 drop(_anthropic);
4962 drop(_openai);
4963 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
4964 assert!(reloaded
4965 .providers
4966 .anthropic
4967 .as_ref()
4968 .map(|a| a.api_key.is_empty())
4969 .unwrap_or(true));
4970 }
4971
4972 #[test]
4973 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
4974 let _lock = env_lock_acquire();
4975 let temp_home = TempHome::new();
4976 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
4977 let target = temp_home.path.join("workspace-default");
4978 std::fs::create_dir_all(&target).expect("default work area dir should exist");
4979
4980 let config = Config {
4981 default_work_area: Some(DefaultWorkAreaConfig {
4982 path: Some("~/workspace-default".to_string()),
4983 }),
4984 ..Default::default()
4985 };
4986
4987 assert_eq!(config.get_default_work_area_path(), Some(target));
4988 }
4989
4990 #[test]
4991 fn get_default_work_area_path_returns_none_for_missing_directory() {
4992 let _lock = env_lock_acquire();
4993 let temp_home = TempHome::new();
4994 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
4995
4996 let config = Config {
4997 default_work_area: Some(DefaultWorkAreaConfig {
4998 path: Some("~/missing-default-work-area".to_string()),
4999 }),
5000 ..Default::default()
5001 };
5002
5003 assert!(config.get_default_work_area_path().is_none());
5004 }
5005
5006 #[test]
5007 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
5008 let mut config = Config::default();
5009 config.tools.disabled = vec![
5010 " read_file ".to_string(),
5011 "".to_string(),
5012 "read_file".to_string(),
5013 "bash".to_string(),
5014 "default::getCurrentDir".to_string(),
5015 ];
5016
5017 config.normalize_tool_settings();
5018
5019 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
5020 }
5021
5022 #[test]
5023 fn config_load_reads_disabled_tools_as_canonical_names() {
5024 let _lock = env_lock_acquire();
5025 let temp_home = TempHome::new();
5026 temp_home.set_config_json(
5027 r#"{
5028 "tools": {
5029 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
5030 }
5031}"#,
5032 );
5033
5034 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5035 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
5036 assert!(config.disabled_tool_names().contains("Bash"));
5037 assert!(config.disabled_tool_names().contains("Read"));
5038 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
5039 }
5040
5041 #[test]
5042 fn normalize_skill_settings_trims_dedupes_and_sorts() {
5043 let mut config = Config::default();
5044 config.skills.disabled = vec![
5045 " pdf ".to_string(),
5046 "".to_string(),
5047 "pdf".to_string(),
5048 "skill-creator".to_string(),
5049 ];
5050
5051 config.normalize_skill_settings();
5052
5053 assert_eq!(
5054 config.skills.disabled,
5055 vec!["pdf".to_string(), "skill-creator".to_string()]
5056 );
5057 }
5058
5059 #[test]
5060 fn config_load_reads_disabled_skills_as_normalized_ids() {
5061 let _lock = env_lock_acquire();
5062 let temp_home = TempHome::new();
5063 temp_home.set_config_json(
5064 r#"{
5065 "skills": {
5066 "disabled": [" pdf ", "skill-creator", "pdf", ""]
5067 }
5068}"#,
5069 );
5070
5071 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5072 assert_eq!(
5073 config.skills.disabled,
5074 vec!["pdf".to_string(), "skill-creator".to_string()]
5075 );
5076 assert!(config.disabled_skill_ids().contains("pdf"));
5077 assert!(config.disabled_skill_ids().contains("skill-creator"));
5078 }
5079
5080 #[test]
5081 fn test_server_config_defaults() {
5082 let _lock = env_lock_acquire();
5083 let temp_home = TempHome::new();
5084
5085 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5086 assert_eq!(config.server.port, 9562);
5087 assert_eq!(config.server.bind, "127.0.0.1");
5088 assert_eq!(config.server.workers, 10);
5089 assert!(config.server.static_dir.is_none());
5090 }
5091
5092 #[test]
5093 fn test_server_addr() {
5094 let mut config = Config::default();
5095 config.server.port = 9000;
5096 config.server.bind = "0.0.0.0".to_string();
5097 assert_eq!(config.server_addr(), "0.0.0.0:9000");
5098 }
5099
5100 #[test]
5101 fn test_env_var_overrides() {
5102 let _lock = env_lock_acquire();
5103 let temp_home = TempHome::new();
5104
5105 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
5106 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
5107 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
5108
5109 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5110 assert_eq!(config.server.port, 9999);
5111 assert_eq!(config.server.bind, "192.168.1.1");
5112 assert_eq!(config.provider, "openai");
5113 }
5114
5115 #[test]
5116 fn test_config_save_and_load() {
5117 let _lock = env_lock_acquire();
5118 let temp_home = TempHome::new();
5119
5120 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
5121 config.server.port = 9000;
5122 config.server.bind = "0.0.0.0".to_string();
5123 config.provider = "anthropic".to_string();
5124
5125 config
5127 .save_to_dir(temp_home.path.clone())
5128 .expect("Failed to save config");
5129
5130 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
5132
5133 assert_eq!(loaded.server.port, 9000);
5135 assert_eq!(loaded.server.bind, "0.0.0.0");
5136 assert_eq!(loaded.provider, "anthropic");
5137 }
5138
5139 #[test]
5140 fn config_decrypts_proxy_auth_from_encrypted_field() {
5141 let _lock = env_lock_acquire();
5142 let temp_home = TempHome::new();
5143
5144 let key_guard = crate::encryption::set_test_encryption_key([
5146 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
5147 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
5148 0x1c, 0x1d, 0x1e, 0x1f,
5149 ]);
5150
5151 let auth = ProxyAuth {
5152 username: "user".to_string(),
5153 password: "pass".to_string(),
5154 };
5155 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
5156 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
5157
5158 temp_home.set_config_json(&format!(
5159 r#"{{
5160 "http_proxy": "http://proxy.example.com:8080",
5161 "proxy_auth_encrypted": "{encrypted}"
5162}}"#
5163 ));
5164 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5165 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
5166 assert_eq!(loaded_auth.username, "user");
5167 assert_eq!(loaded_auth.password, "pass");
5168 drop(key_guard);
5169 }
5170
5171 #[test]
5172 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
5173 let _lock = env_lock_acquire();
5174 let temp_home = TempHome::new();
5175
5176 let key_guard = crate::encryption::set_test_encryption_key([
5178 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
5179 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
5180 0x1c, 0x1d, 0x1e, 0x1f,
5181 ]);
5182
5183 let auth = ProxyAuth {
5184 username: "user".to_string(),
5185 password: "pass".to_string(),
5186 };
5187 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
5188 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
5189
5190 temp_home.set_config_json(&format!(
5192 r#"{{
5193 "http_proxy": "http://proxy.example.com:8080",
5194 "http_proxy_auth_encrypted": "{encrypted}",
5195 "https_proxy_auth_encrypted": "{encrypted}"
5196}}"#
5197 ));
5198
5199 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5200 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
5201 assert_eq!(loaded_auth.username, "user");
5202 assert_eq!(loaded_auth.password, "pass");
5203 drop(key_guard);
5204 }
5205
5206 #[test]
5207 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
5208 let _lock = env_lock_acquire();
5209 let temp_home = TempHome::new();
5210
5211 let key_guard = crate::encryption::set_test_encryption_key([
5213 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
5214 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
5215 0x1c, 0x1d, 0x1e, 0x1f,
5216 ]);
5217
5218 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
5219 config.proxy_auth = Some(ProxyAuth {
5220 username: "user".to_string(),
5221 password: "pass".to_string(),
5222 });
5223 config
5224 .save_to_dir(temp_home.path.clone())
5225 .expect("save should encrypt proxy auth");
5226
5227 let content =
5228 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
5229 assert!(
5230 content.contains("proxy_auth_encrypted"),
5231 "config.json should store encrypted proxy auth"
5232 );
5233 assert!(
5234 !content.contains("\"proxy_auth\""),
5235 "config.json should not store plaintext proxy_auth"
5236 );
5237
5238 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
5239 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
5240 assert_eq!(loaded_auth.username, "user");
5241 assert_eq!(loaded_auth.password, "pass");
5242 drop(key_guard);
5243 }
5244
5245 #[test]
5246 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
5247 let _lock = env_lock_acquire();
5248 let temp_home = TempHome::new();
5249
5250 let key_guard = crate::encryption::set_test_encryption_key([
5252 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
5253 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
5254 0x1c, 0x1d, 0x1e, 0x1f,
5255 ]);
5256
5257 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
5258 config.provider = "openai".to_string();
5259 config.providers.openai = Some(OpenAIConfig {
5260 api_key: "sk-test-provider-key".to_string(),
5261 api_key_encrypted: None,
5262 base_url: None,
5263 model: None,
5264 fast_model: None,
5265 vision_model: None,
5266 reasoning_effort: None,
5267 responses_only_models: vec![],
5268 request_overrides: None,
5269 extra: Default::default(),
5270 api_key_from_env: false,
5271 });
5272
5273 config
5274 .save_to_dir(temp_home.path.clone())
5275 .expect("save should encrypt provider api keys");
5276
5277 let content =
5278 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
5279 assert!(
5280 content.contains("\"api_key_encrypted\""),
5281 "config.json should store encrypted provider keys"
5282 );
5283 assert!(
5284 !content.contains("\"api_key\""),
5285 "config.json should not store plaintext provider keys"
5286 );
5287
5288 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
5289 let openai = loaded
5290 .providers
5291 .openai
5292 .expect("openai config should be present");
5293 assert_eq!(openai.api_key, "sk-test-provider-key");
5294
5295 drop(key_guard);
5296 }
5297
5298 #[test]
5299 fn config_save_persists_mcp_servers_in_mainstream_format() {
5300 let _lock = env_lock_acquire();
5301 let temp_home = TempHome::new();
5302
5303 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
5304
5305 let mut env = std::collections::HashMap::new();
5306 env.insert("TOKEN".to_string(), "supersecret".to_string());
5307
5308 config.mcp.servers = vec![
5309 bamboo_domain::mcp_config::McpServerConfig {
5310 id: "stdio-secret".to_string(),
5311 name: None,
5312 enabled: true,
5313 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
5314 bamboo_domain::mcp_config::StdioConfig {
5315 command: "echo".to_string(),
5316 args: vec![],
5317 cwd: None,
5318 env,
5319 env_encrypted: std::collections::HashMap::new(),
5320 startup_timeout_ms: 5000,
5321 },
5322 ),
5323 request_timeout_ms: 5000,
5324 healthcheck_interval_ms: 1000,
5325 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
5326 allowed_tools: vec![],
5327 denied_tools: vec![],
5328 },
5329 bamboo_domain::mcp_config::McpServerConfig {
5330 id: "sse-secret".to_string(),
5331 name: None,
5332 enabled: true,
5333 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
5334 bamboo_domain::mcp_config::SseConfig {
5335 url: "http://localhost:8080/sse".to_string(),
5336 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
5337 name: "Authorization".to_string(),
5338 value: "Bearer token123".to_string(),
5339 value_encrypted: None,
5340 }],
5341 connect_timeout_ms: 5000,
5342 },
5343 ),
5344 request_timeout_ms: 5000,
5345 healthcheck_interval_ms: 1000,
5346 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
5347 allowed_tools: vec![],
5348 denied_tools: vec![],
5349 },
5350 ];
5351
5352 config
5353 .save_to_dir(temp_home.path.clone())
5354 .expect("save should persist MCP servers");
5355
5356 let content =
5357 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
5358 assert!(
5359 content.contains("\"mcpServers\""),
5360 "config.json should store MCP servers under the mainstream 'mcpServers' key"
5361 );
5362 assert!(
5363 content.contains("supersecret"),
5364 "config.json should persist MCP stdio env in mainstream format"
5365 );
5366 assert!(
5367 content.contains("Bearer token123"),
5368 "config.json should persist MCP SSE headers in mainstream format"
5369 );
5370 assert!(
5371 !content.contains("\"env_encrypted\""),
5372 "config.json should not persist legacy env_encrypted fields"
5373 );
5374 assert!(
5375 !content.contains("\"value_encrypted\""),
5376 "config.json should not persist legacy value_encrypted fields"
5377 );
5378
5379 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
5380 let stdio = loaded
5381 .mcp
5382 .servers
5383 .iter()
5384 .find(|s| s.id == "stdio-secret")
5385 .expect("stdio server should exist");
5386 match &stdio.transport {
5387 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
5388 assert_eq!(
5389 stdio.env.get("TOKEN").map(|s| s.as_str()),
5390 Some("supersecret")
5391 );
5392 }
5393 _ => panic!("Expected stdio transport"),
5394 }
5395
5396 let sse = loaded
5397 .mcp
5398 .servers
5399 .iter()
5400 .find(|s| s.id == "sse-secret")
5401 .expect("sse server should exist");
5402 match &sse.transport {
5403 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
5404 assert_eq!(sse.headers[0].value, "Bearer token123");
5405 }
5406 _ => panic!("Expected SSE transport"),
5407 }
5408 }
5409
5410 #[test]
5413 fn env_vars_as_map_includes_only_non_empty_values() {
5414 let config = Config {
5415 env_vars: vec![
5416 EnvVarEntry {
5417 name: "A".to_string(),
5418 value: "val_a".to_string(),
5419 secret: false,
5420 value_encrypted: None,
5421 description: None,
5422 },
5423 EnvVarEntry {
5424 name: "B".to_string(),
5425 value: "".to_string(), secret: true,
5427 value_encrypted: None,
5428 description: None,
5429 },
5430 EnvVarEntry {
5431 name: "C".to_string(),
5432 value: " ".to_string(), secret: false,
5434 value_encrypted: None,
5435 description: None,
5436 },
5437 EnvVarEntry {
5438 name: "D".to_string(),
5439 value: "val_d".to_string(),
5440 secret: true,
5441 value_encrypted: Some("enc".to_string()),
5442 description: Some("desc".to_string()),
5443 },
5444 ],
5445 ..Default::default()
5446 };
5447
5448 let map = config.env_vars_as_map();
5449 assert_eq!(map.len(), 2);
5450 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
5451 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
5452 assert!(!map.contains_key("B"));
5453 assert!(!map.contains_key("C"));
5454 }
5455
5456 #[test]
5457 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
5458 let mut config = Config {
5459 env_vars: vec![
5460 EnvVarEntry {
5461 name: "PLAIN".to_string(),
5462 value: "visible".to_string(),
5463 secret: false,
5464 value_encrypted: None,
5465 description: None,
5466 },
5467 EnvVarEntry {
5468 name: "SECRET".to_string(),
5469 value: "hidden_value".to_string(),
5470 secret: true,
5471 value_encrypted: Some("enc_data".to_string()),
5472 description: None,
5473 },
5474 ],
5475 ..Default::default()
5476 };
5477
5478 config.sanitize_env_vars_for_disk();
5479
5480 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
5483
5484 #[test]
5485 fn sanitize_env_vars_for_disk_preserves_encrypted() {
5486 let mut config = Config {
5487 env_vars: vec![
5488 EnvVarEntry {
5489 name: "OPEN".to_string(),
5490 value: "val".to_string(),
5491 secret: false,
5492 value_encrypted: None,
5493 description: None,
5494 },
5495 EnvVarEntry {
5496 name: "HIDDEN".to_string(),
5497 value: "real_secret".to_string(),
5498 secret: true,
5499 value_encrypted: Some("enc".to_string()),
5500 description: None,
5501 },
5502 ],
5503 ..Default::default()
5504 };
5505
5506 config.sanitize_env_vars_for_disk();
5507
5508 assert_eq!(config.env_vars[0].value, "val");
5510 assert_eq!(config.env_vars[1].value, "");
5512 assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
5513 }
5514
5515 #[test]
5516 fn refresh_env_vars_encrypted_round_trip() {
5517 let mut config = Config {
5518 env_vars: vec![
5519 EnvVarEntry {
5520 name: "TOKEN".to_string(),
5521 value: "my-secret-token".to_string(),
5522 secret: true,
5523 value_encrypted: None,
5524 description: Some("A token".to_string()),
5525 },
5526 EnvVarEntry {
5527 name: "PLAIN_VAR".to_string(),
5528 value: "hello".to_string(),
5529 secret: false,
5530 value_encrypted: None,
5531 description: None,
5532 },
5533 ],
5534 ..Default::default()
5535 };
5536
5537 config
5539 .refresh_env_vars_encrypted()
5540 .expect("encryption should succeed");
5541
5542 assert!(config.env_vars[0].value_encrypted.is_some());
5544 assert!(config.env_vars[1].value_encrypted.is_none());
5546
5547 let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
5549 assert_ne!(encrypted, "my-secret-token"); config.sanitize_env_vars_for_disk();
5553 assert_eq!(config.env_vars[0].value, "");
5554
5555 config.hydrate_env_vars_from_encrypted();
5557 assert_eq!(config.env_vars[0].value, "my-secret-token");
5558 assert_eq!(config.env_vars[1].value, "hello"); }
5560
5561 #[test]
5562 fn publish_and_current_env_vars_round_trip() {
5563 let config = Config {
5564 env_vars: vec![EnvVarEntry {
5565 name: "TEST_PUBLISH".to_string(),
5566 value: "pub_value".to_string(),
5567 secret: false,
5568 value_encrypted: None,
5569 description: None,
5570 }],
5571 ..Default::default()
5572 };
5573
5574 for _ in 0..10 {
5575 config.publish_env_vars();
5576 let map = Config::current_env_vars();
5577 if map.get("TEST_PUBLISH") == Some(&"pub_value".to_string()) {
5578 return;
5579 }
5580 }
5581 panic!("TEST_PUBLISH not found in cache after retries");
5582 }
5583
5584 #[test]
5585 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
5586 let mut config = Config::default();
5587 config.subagents.broker = Some(BrokerClientConfig {
5588 endpoint: "ws://127.0.0.1:9600".to_string(),
5589 token: "super-secret-token".to_string(),
5590 token_encrypted: None,
5591 });
5592
5593 config.refresh_broker_token_encrypted().unwrap();
5595 config.sanitize_broker_token_for_disk();
5596 let broker = config.subagents.broker.as_ref().unwrap();
5597 assert!(broker.token.is_empty(), "plaintext cleared for disk");
5598 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
5599 assert_ne!(
5600 broker.token_encrypted.as_deref(),
5601 Some("super-secret-token")
5602 );
5603
5604 config.hydrate_broker_token_from_encrypted();
5606 assert_eq!(
5607 config.subagents.broker.as_ref().unwrap().token,
5608 "super-secret-token"
5609 );
5610 }
5611
5612 #[test]
5613 fn broker_token_empty_refresh_preserves_ciphertext() {
5614 let mut config = Config::default();
5616 config.subagents.broker = Some(BrokerClientConfig {
5617 endpoint: "ws://h:9600".to_string(),
5618 token: String::new(),
5619 token_encrypted: Some("existing-cipher".to_string()),
5620 });
5621 config.refresh_broker_token_encrypted().unwrap();
5622 assert_eq!(
5623 config
5624 .subagents
5625 .broker
5626 .as_ref()
5627 .unwrap()
5628 .token_encrypted
5629 .as_deref(),
5630 Some("existing-cipher"),
5631 );
5632 }
5633
5634 #[test]
5635 fn notifications_config_defaults_when_key_missing() {
5636 let config: Config = serde_json::from_str("{}").expect("empty object parses");
5639 assert_eq!(config.notifications, NotificationsConfig::default());
5640 assert_eq!(config.notifications.desktop.enabled, None);
5641 assert!(!config.notifications.ntfy.enabled);
5642 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
5643 assert_eq!(config.notifications.ntfy.token, None);
5644 assert!(!config.notifications.bark.enabled);
5645 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
5646 assert_eq!(config.notifications.bark.device_key, None);
5647 }
5648
5649 #[test]
5650 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
5651 let mut config = Config::default();
5652 config.notifications.ntfy = NtfyChannelConfig {
5653 enabled: true,
5654 base_url: "https://ntfy.sh".to_string(),
5655 topic: "bamboo-alerts".to_string(),
5656 token: Some("tk_super_secret".to_string()),
5657 token_encrypted: None,
5658 };
5659
5660 config.refresh_notifications_encrypted().unwrap();
5662 assert!(config.notifications.ntfy.token_encrypted.is_some());
5663 assert_ne!(
5664 config.notifications.ntfy.token_encrypted.as_deref(),
5665 Some("tk_super_secret")
5666 );
5667
5668 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
5671 assert!(
5672 !json.contains("tk_super_secret"),
5673 "plaintext token must never be serialized"
5674 );
5675 assert!(json.contains("token_encrypted"));
5676
5677 config.notifications.ntfy.token = None;
5680 config.hydrate_notifications_from_encrypted();
5681 assert_eq!(
5682 config.notifications.ntfy.token.as_deref(),
5683 Some("tk_super_secret")
5684 );
5685 }
5686
5687 #[test]
5688 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
5689 let mut config = Config::default();
5690 config.notifications.bark = BarkChannelConfig {
5691 enabled: true,
5692 base_url: "https://api.day.app".to_string(),
5693 device_key: Some("dk_super_secret".to_string()),
5694 device_key_encrypted: None,
5695 };
5696
5697 config.refresh_notifications_encrypted().unwrap();
5698 assert!(config.notifications.bark.device_key_encrypted.is_some());
5699 assert_ne!(
5700 config.notifications.bark.device_key_encrypted.as_deref(),
5701 Some("dk_super_secret")
5702 );
5703
5704 let json = serde_json::to_string(&config.notifications.bark).unwrap();
5705 assert!(
5706 !json.contains("dk_super_secret"),
5707 "plaintext device key must never be serialized"
5708 );
5709 assert!(json.contains("device_key_encrypted"));
5710
5711 config.notifications.bark.device_key = None;
5712 config.hydrate_notifications_from_encrypted();
5713 assert_eq!(
5714 config.notifications.bark.device_key.as_deref(),
5715 Some("dk_super_secret")
5716 );
5717 }
5718
5719 #[test]
5720 fn notification_secrets_empty_refresh_preserves_ciphertext() {
5721 let mut config = Config::default();
5724 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
5725 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
5726
5727 config.refresh_notifications_encrypted().unwrap();
5728
5729 assert_eq!(
5730 config.notifications.ntfy.token_encrypted.as_deref(),
5731 Some("existing-ntfy-cipher")
5732 );
5733 assert_eq!(
5734 config.notifications.bark.device_key_encrypted.as_deref(),
5735 Some("existing-bark-cipher")
5736 );
5737 }
5738
5739 #[test]
5740 fn hydrate_skips_non_secret_entries() {
5741 let mut config = Config {
5742 env_vars: vec![EnvVarEntry {
5743 name: "PLAIN".to_string(),
5744 value: "original".to_string(),
5745 secret: false,
5746 value_encrypted: Some("should-be-ignored".to_string()),
5747 description: None,
5748 }],
5749 ..Default::default()
5750 };
5751
5752 config.hydrate_env_vars_from_encrypted();
5753 assert_eq!(config.env_vars[0].value, "original");
5755 }
5756
5757 #[test]
5758 fn default_config_has_empty_env_vars() {
5759 assert!(Config::default().env_vars.is_empty());
5764 }
5765
5766 #[test]
5767 fn serde_round_trip_with_env_vars() {
5768 let config = Config {
5769 env_vars: vec![
5770 EnvVarEntry {
5771 name: "KEY1".to_string(),
5772 value: "val1".to_string(),
5773 secret: false,
5774 value_encrypted: None,
5775 description: Some("First key".to_string()),
5776 },
5777 EnvVarEntry {
5778 name: "KEY2".to_string(),
5779 value: "".to_string(), secret: true,
5781 value_encrypted: Some("enc123".to_string()),
5782 description: None,
5783 },
5784 ],
5785 ..Default::default()
5786 };
5787
5788 let json = serde_json::to_string(&config).unwrap();
5789 let restored: Config = serde_json::from_str(&json).unwrap();
5790
5791 assert_eq!(restored.env_vars.len(), 2);
5792 assert_eq!(restored.env_vars[0].name, "KEY1");
5793 assert_eq!(restored.env_vars[0].value, "val1");
5794 assert!(!restored.env_vars[0].secret);
5795 assert_eq!(restored.env_vars[1].name, "KEY2");
5796 assert!(restored.env_vars[1].secret);
5797 assert_eq!(
5798 restored.env_vars[1].value_encrypted.as_deref(),
5799 Some("enc123")
5800 );
5801 }
5802
5803 #[test]
5806 #[allow(clippy::field_reassign_with_default)]
5808 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
5809 let mut config = Config::default();
5810 config.provider = "openai".to_string();
5811 config.providers.openai = Some(OpenAIConfig {
5812 api_key: "test".to_string(),
5813 api_key_encrypted: None,
5814 base_url: None,
5815 model: Some("legacy-gpt-4o".to_string()),
5816 fast_model: None,
5817 vision_model: None,
5818 reasoning_effort: None,
5819 responses_only_models: vec![],
5820 request_overrides: None,
5821 extra: Default::default(),
5822 api_key_from_env: false,
5823 });
5824 config.features.provider_model_ref = true;
5825 config.defaults = Some(DefaultsConfig {
5826 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
5827 fast: None,
5828 task_summary: None,
5829 vision: None,
5830 memory_background: None,
5831 planning: None,
5832 search: None,
5833 code_review: None,
5834 sub_agent: None,
5835 subagent_models: Default::default(),
5836 });
5837
5838 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
5839 }
5840
5841 #[test]
5842 #[allow(clippy::field_reassign_with_default)]
5844 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
5845 let mut config = Config::default();
5846 config.provider = "openai".to_string();
5847 config.providers.openai = Some(OpenAIConfig {
5848 api_key: "test".to_string(),
5849 api_key_encrypted: None,
5850 base_url: None,
5851 model: Some("legacy-gpt-4o".to_string()),
5852 fast_model: None,
5853 vision_model: None,
5854 reasoning_effort: None,
5855 responses_only_models: vec![],
5856 request_overrides: None,
5857 extra: Default::default(),
5858 api_key_from_env: false,
5859 });
5860 config.features.provider_model_ref = false;
5861 config.defaults = Some(DefaultsConfig {
5862 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
5863 fast: None,
5864 task_summary: None,
5865 vision: None,
5866 memory_background: None,
5867 planning: None,
5868 search: None,
5869 code_review: None,
5870 sub_agent: None,
5871 subagent_models: Default::default(),
5872 });
5873
5874 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
5875 }
5876
5877 #[test]
5878 #[allow(clippy::field_reassign_with_default)]
5880 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
5881 let mut config = Config::default();
5882 config.provider = "openai".to_string();
5883 config.providers.openai = Some(OpenAIConfig {
5884 api_key: "test".to_string(),
5885 api_key_encrypted: None,
5886 base_url: None,
5887 model: Some("gpt-4o".to_string()),
5888 fast_model: Some("legacy-gpt-4o-mini".to_string()),
5889 vision_model: None,
5890 reasoning_effort: None,
5891 responses_only_models: vec![],
5892 request_overrides: None,
5893 extra: Default::default(),
5894 api_key_from_env: false,
5895 });
5896 config.features.provider_model_ref = true;
5897 config.defaults = Some(DefaultsConfig {
5898 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
5899 fast: Some(bamboo_domain::ProviderModelRef::new(
5900 "anthropic",
5901 "claude-3-5-haiku",
5902 )),
5903 task_summary: None,
5904 vision: None,
5905 memory_background: None,
5906 planning: None,
5907 search: None,
5908 code_review: None,
5909 sub_agent: None,
5910 subagent_models: Default::default(),
5911 });
5912
5913 assert_eq!(
5914 config.get_fast_model(),
5915 Some("claude-3-5-haiku".to_string())
5916 );
5917 }
5918
5919 #[test]
5920 #[allow(clippy::field_reassign_with_default)]
5922 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
5923 let mut config = Config::default();
5924 config.provider = "openai".to_string();
5925 config.providers.openai = Some(OpenAIConfig {
5926 api_key: "test".to_string(),
5927 api_key_encrypted: None,
5928 base_url: None,
5929 model: Some("gpt-4o".to_string()),
5930 fast_model: Some("legacy-gpt-4o-mini".to_string()),
5931 vision_model: None,
5932 reasoning_effort: None,
5933 responses_only_models: vec![],
5934 request_overrides: None,
5935 extra: Default::default(),
5936 api_key_from_env: false,
5937 });
5938 config.features.provider_model_ref = false;
5939 config.defaults = Some(DefaultsConfig {
5940 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
5941 fast: Some(bamboo_domain::ProviderModelRef::new(
5942 "anthropic",
5943 "claude-3-5-haiku",
5944 )),
5945 task_summary: None,
5946 vision: None,
5947 memory_background: None,
5948 planning: None,
5949 search: None,
5950 code_review: None,
5951 sub_agent: None,
5952 subagent_models: Default::default(),
5953 });
5954
5955 assert_eq!(
5956 config.get_fast_model(),
5957 Some("legacy-gpt-4o-mini".to_string())
5958 );
5959 }
5960
5961 #[test]
5962 #[allow(clippy::field_reassign_with_default)]
5964 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
5965 let mut config = Config::default();
5966 config.provider = "openai".to_string();
5967 config.features.provider_model_ref = true;
5968 config.defaults = Some(DefaultsConfig {
5969 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
5970 fast: None,
5971 task_summary: None,
5972 vision: None,
5973 memory_background: None,
5974 planning: None,
5975 search: None,
5976 code_review: None,
5977 sub_agent: None,
5978 subagent_models: Default::default(),
5979 });
5980
5981 assert_eq!(
5982 config.get_fast_model(),
5983 Some("claude-3-7-sonnet".to_string())
5984 );
5985 }
5986
5987 #[test]
5988 #[allow(clippy::field_reassign_with_default)]
5990 fn get_memory_background_model_prefers_defaults_memory_background() {
5991 let mut config = Config::default();
5992 config.provider = "openai".to_string();
5993 config.providers.openai = Some(OpenAIConfig {
5994 api_key: "test".to_string(),
5995 api_key_encrypted: None,
5996 base_url: None,
5997 model: Some("gpt-4o".to_string()),
5998 fast_model: Some("gpt-4o-mini".to_string()),
5999 vision_model: None,
6000 reasoning_effort: None,
6001 responses_only_models: vec![],
6002 request_overrides: None,
6003 extra: Default::default(),
6004 api_key_from_env: false,
6005 });
6006 config.features.provider_model_ref = true;
6007 config.defaults = Some(DefaultsConfig {
6008 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
6009 fast: Some(bamboo_domain::ProviderModelRef::new(
6010 "openai",
6011 "gpt-4o-mini",
6012 )),
6013 task_summary: None,
6014 vision: None,
6015 memory_background: Some(bamboo_domain::ProviderModelRef::new(
6016 "anthropic",
6017 "claude-3-5-haiku",
6018 )),
6019 planning: None,
6020 search: None,
6021 code_review: None,
6022 sub_agent: None,
6023 subagent_models: Default::default(),
6024 });
6025
6026 assert_eq!(
6027 config.get_memory_background_model(),
6028 Some("claude-3-5-haiku".to_string())
6029 );
6030 }
6031
6032 #[test]
6033 #[allow(clippy::field_reassign_with_default)]
6035 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
6036 let mut config = Config::default();
6037 config.provider = "openai".to_string();
6038 config.features.provider_model_ref = true;
6039 config.defaults = Some(DefaultsConfig {
6040 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
6041 fast: Some(bamboo_domain::ProviderModelRef::new(
6042 "anthropic",
6043 "claude-3-5-haiku",
6044 )),
6045 task_summary: None,
6046 vision: None,
6047 memory_background: None,
6048 planning: None,
6049 search: None,
6050 code_review: None,
6051 sub_agent: None,
6052 subagent_models: Default::default(),
6053 });
6054
6055 assert_eq!(
6056 config.get_memory_background_model(),
6057 Some("claude-3-5-haiku".to_string())
6058 );
6059 }
6060
6061 #[test]
6062 #[allow(clippy::field_reassign_with_default)]
6064 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
6065 let mut config = Config::default();
6066 config.provider = "openai".to_string();
6067 config.providers.openai = Some(OpenAIConfig {
6068 api_key: "test".to_string(),
6069 api_key_encrypted: None,
6070 base_url: None,
6071 model: Some("gpt-4o".to_string()),
6072 fast_model: Some("legacy-gpt-4o-mini".to_string()),
6073 vision_model: None,
6074 reasoning_effort: None,
6075 responses_only_models: vec![],
6076 request_overrides: None,
6077 extra: Default::default(),
6078 api_key_from_env: false,
6079 });
6080 config.features.provider_model_ref = false;
6081 config.defaults = Some(DefaultsConfig {
6082 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
6083 fast: Some(bamboo_domain::ProviderModelRef::new(
6084 "anthropic",
6085 "claude-3-5-haiku",
6086 )),
6087 task_summary: None,
6088 vision: None,
6089 memory_background: Some(bamboo_domain::ProviderModelRef::new(
6090 "anthropic",
6091 "claude-3-5-haiku",
6092 )),
6093 planning: None,
6094 search: None,
6095 code_review: None,
6096 sub_agent: None,
6097 subagent_models: Default::default(),
6098 });
6099
6100 assert_eq!(
6101 config.get_memory_background_model(),
6102 Some("legacy-gpt-4o-mini".to_string())
6103 );
6104 }
6105
6106 #[test]
6113 fn is_host_trusted_requires_https_scheme() {
6114 let hosts = vec!["github.com/bigduu/".to_string()];
6115 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
6116 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
6117 }
6118
6119 #[test]
6120 fn is_host_trusted_is_case_insensitive_on_both_sides() {
6121 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
6123 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
6124 let hosts = vec!["github.com/bigduu/".to_string()];
6126 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
6127 }
6128
6129 #[test]
6130 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
6131 let hosts = vec!["trusted.example.com".to_string()];
6132 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
6133 assert!(!is_host_trusted(
6137 "https://trusted.example.com.evil.com/x",
6138 &hosts
6139 ));
6140 assert!(!is_host_trusted(
6141 "https://trusted.example.comevil.com/x",
6142 &hosts
6143 ));
6144 }
6145
6146 #[test]
6147 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
6148 let hosts = vec!["github.com/bigduu/".to_string()];
6150 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
6151 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
6152 }
6153
6154 #[test]
6155 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
6156 let hosts = vec!["example.com".to_string()];
6157 assert!(is_host_trusted("https://example.com/", &hosts));
6158 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
6159 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
6162 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
6163 }
6164
6165 #[test]
6166 fn is_host_trusted_uses_the_real_host_not_userinfo() {
6167 let hosts = vec!["github.com/bigduu/".to_string()];
6168 assert!(is_host_trusted(
6170 "https://someuser@github.com/bigduu/x",
6171 &hosts
6172 ));
6173 assert!(!is_host_trusted(
6176 "https://github.com@evil.com/bigduu/",
6177 &hosts
6178 ));
6179 }
6180
6181 #[test]
6182 fn is_host_trusted_ignores_an_explicit_port() {
6183 let hosts = vec!["github.com/bigduu/".to_string()];
6184 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
6185 }
6186
6187 #[test]
6188 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
6189 let hosts = vec!["github.com/bigduu/".to_string()];
6190 assert!(!is_host_trusted("not a url at all", &hosts));
6191 assert!(!is_host_trusted("", &hosts));
6192 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
6194
6195 #[test]
6196 fn is_host_trusted_normalizes_dot_segments_before_matching() {
6197 let hosts = vec!["github.com/bigduu/".to_string()];
6198 assert!(!is_host_trusted(
6201 "https://github.com/bigduu/../evil/x",
6202 &hosts
6203 ));
6204 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
6206 }
6207
6208 #[test]
6209 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
6210 let mut config = Config::default();
6211 config.plugin_trust.trusted_hosts = vec![
6212 " GitHub.com/BigDuu/ ".to_string(),
6213 "".to_string(),
6214 " ".to_string(),
6215 "Example.COM".to_string(),
6216 ];
6217 config.normalize_plugin_trust_settings();
6218 assert_eq!(
6219 config.plugin_trust.trusted_hosts,
6220 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
6221 );
6222 }
6223
6224 #[test]
6230 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
6231 let config = Config::default();
6233 assert_eq!(
6234 config.plugin_trust.enforcement,
6235 PluginTrustEnforcement::Strict
6236 );
6237 assert!(!config.plugin_trust.enforcement_is_off());
6238
6239 let json = serde_json::json!({
6245 "trusted_hosts": ["example.com"],
6246 "trusted_keys": [],
6247 });
6248 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
6249 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
6250 }
6251
6252 #[test]
6253 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
6254 for raw in ["off", "OFF", "Off", " off "] {
6255 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
6256 "enforcement": raw,
6257 }))
6258 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
6259 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
6260 assert!(trust.enforcement_is_off());
6261 }
6262 for raw in ["strict", "STRICT", " Strict "] {
6263 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
6264 "enforcement": raw,
6265 }))
6266 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
6267 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
6268 }
6269
6270 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
6271 "enforcement": "nonsense",
6272 }))
6273 .expect_err("an unrecognized string must be rejected, not silently default");
6274 assert!(err.to_string().contains("nonsense"));
6275 }
6276
6277 #[test]
6278 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
6279 let trust: PluginTrustConfig =
6282 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
6283 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
6284
6285 let trust: PluginTrustConfig =
6286 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
6287 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
6288 }
6289
6290 #[test]
6291 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
6292 let trust = PluginTrustConfig {
6297 enforcement: PluginTrustEnforcement::Off,
6298 ..PluginTrustConfig::default()
6299 };
6300 let json = serde_json::to_value(&trust).unwrap();
6301 assert_eq!(json["enforcement"], "off");
6302
6303 let trust = PluginTrustConfig {
6304 enforcement: PluginTrustEnforcement::Strict,
6305 ..PluginTrustConfig::default()
6306 };
6307 let json = serde_json::to_value(&trust).unwrap();
6308 assert_eq!(json["enforcement"], "strict");
6309 }
6310
6311 #[test]
6312 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
6313 let mut config = Config::default();
6316 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
6317 config.normalize_plugin_trust_settings();
6318 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
6319 }
6320
6321 #[test]
6322 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
6323 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
6328 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
6329 )));
6330 assert_eq!(
6331 config.plugin_trust.enforcement,
6332 PluginTrustEnforcement::Strict
6333 );
6334
6335 let outcome = crate::dot_path::apply_dot_path_set(
6336 &config,
6337 "plugin_trust.enforcement",
6338 crate::dot_path::parse_cli_value("off"),
6339 )
6340 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
6341 assert_eq!(
6342 outcome.config.plugin_trust.enforcement,
6343 PluginTrustEnforcement::Off
6344 );
6345
6346 let outcome = crate::dot_path::apply_dot_path_set(
6348 &outcome.config,
6349 "plugin_trust.enforcement",
6350 crate::dot_path::parse_cli_value("strict"),
6351 )
6352 .expect("setting it back to strict should also round-trip");
6353 assert_eq!(
6354 outcome.config.plugin_trust.enforcement,
6355 PluginTrustEnforcement::Strict
6356 );
6357 }
6358}