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 #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
284 pub granularity_freshness_gardener_enabled: bool,
285}
286
287impl Default for MemoryConfig {
288 fn default() -> Self {
289 Self {
290 background_model: None,
291 auto_dream_enabled: default_true_auto_dream_enabled(),
292 auto_dream_interval_secs: default_auto_dream_interval_secs(),
293 project_prompt_injection: default_true_memory_project_prompt_injection(),
294 relevant_recall: default_true_memory_relevant_recall(),
295 relevant_recall_rerank: false,
296 project_first_dream: default_true_memory_project_first_dream(),
297 ledger_agenda_injection: default_true_memory_ledger_agenda(),
298 ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
299 ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
300 ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
301 dream_refine_mode: false,
302 gardener_enabled: default_true_gardener_enabled(),
303 gardener_interval_secs: default_gardener_interval_secs(),
304 gardener_volume_trigger: default_gardener_volume_trigger(),
305 gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
306 gardener_min_sections: default_gardener_min_sections(),
307 dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
308 dedup_gardener_min_score: default_dedup_gardener_min_score(),
309 dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
310 memory_active_capacity: 0,
311 capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
312 granularity_freshness_gardener_enabled:
313 default_true_granularity_freshness_gardener_enabled(),
314 }
315 }
316}
317
318fn default_true_granularity_freshness_gardener_enabled() -> bool {
319 true
320}
321
322fn default_capacity_max_archivals_per_run() -> usize {
323 50
324}
325
326fn default_true_auto_dream_enabled() -> bool {
327 true
328}
329
330fn default_true_gardener_enabled() -> bool {
331 true
332}
333
334fn default_true_dedup_gardener_enabled() -> bool {
335 true
336}
337
338fn default_true_memory_ledger_agenda() -> bool {
339 true
340}
341
342fn default_true_ledger_gardener_enabled() -> bool {
343 true
344}
345
346fn default_ledger_gardener_interval_secs() -> u64 {
347 21_600
348}
349
350fn default_true_ledger_distillation_enabled() -> bool {
351 true
352}
353
354fn default_gardener_volume_trigger() -> usize {
358 25
359}
360
361fn default_gardener_interval_secs() -> u64 {
362 86_400
363}
364
365fn default_auto_dream_interval_secs() -> u64 {
366 60 * 30
367}
368
369fn default_gardener_max_splits_per_run() -> usize {
370 8
371}
372
373fn default_gardener_min_sections() -> usize {
374 5
375}
376
377fn default_dedup_gardener_min_score() -> f64 {
378 0.6
379}
380
381fn default_dedup_gardener_max_merges_per_run() -> usize {
382 8
383}
384
385fn default_true_memory_project_prompt_injection() -> bool {
386 true
387}
388
389fn default_true_memory_relevant_recall() -> bool {
390 true
391}
392
393fn default_true_memory_project_first_dream() -> bool {
394 true
395}
396
397#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
414pub struct RunBudgetConfig {
415 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub max_total_tokens: Option<u64>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub max_tool_calls: Option<u32>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub max_subagents: Option<u32>,
430}
431
432fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
435 match (config_default, request) {
436 (Some(a), Some(b)) => Some(a.min(b)),
437 (Some(a), None) => Some(a),
438 (None, Some(b)) => Some(b),
439 (None, None) => None,
440 }
441}
442
443impl RunBudgetConfig {
444 pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
459 let Some(over) = request_override else {
460 return *self;
461 };
462 Self {
463 max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
464 max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
465 max_subagents: min_limit(self.max_subagents, over.max_subagents),
466 }
467 }
468}
469
470#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
481pub struct SubagentsConfig {
482 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub max_concurrent: Option<usize>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub worker_bin: Option<String>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub worker_args: Option<Vec<String>>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub fabric_dir: Option<String>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub executor: Option<String>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub claude_code_binary: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub claude_code_model: Option<String>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub claude_code_permission_mode: Option<String>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub claude_code_inherit_user_config: Option<bool>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub claude_code_forward_env: Option<Vec<String>>,
528 #[serde(skip)]
535 pub broker: Option<BrokerClientConfig>,
536 #[serde(default, skip_serializing_if = "Vec::is_empty")]
542 pub remote_placements: Vec<RemoteActorPlacement>,
543 #[serde(default, skip_serializing_if = "Vec::is_empty")]
556 pub schedulable_placements: Vec<SchedulablePlacement>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
576 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
577}
578
579#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
582pub struct McpRoleAllowlistEntry {
583 pub role: String,
591 #[serde(default)]
599 pub tools: Vec<String>,
600}
601
602#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
618pub struct SchedulablePlacement {
619 pub role: String,
622 pub pool: String,
624 #[serde(default, skip_serializing_if = "String::is_empty")]
628 pub registry_url: String,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub token_env: Option<String>,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub ca_cert_file: Option<String>,
638}
639
640#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
651pub struct RemoteActorPlacement {
652 pub role: String,
655 pub endpoint: String,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub token_env: Option<String>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub ca_cert_file: Option<String>,
666}
667
668#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
670pub struct BrokerClientConfig {
671 pub endpoint: String,
673 #[serde(default)]
678 pub token: String,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub token_encrypted: Option<String>,
682}
683
684#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
686pub struct DesktopChannelConfig {
687 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub enabled: Option<bool>,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct NtfyChannelConfig {
700 #[serde(default)]
701 pub enabled: bool,
702 #[serde(default = "default_ntfy_base_url")]
704 pub base_url: String,
705 #[serde(default)]
708 pub topic: String,
709 #[serde(default, skip_serializing)]
716 pub token: Option<String>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub token_encrypted: Option<String>,
720}
721
722impl Default for NtfyChannelConfig {
723 fn default() -> Self {
724 Self {
725 enabled: false,
726 base_url: default_ntfy_base_url(),
727 topic: String::new(),
728 token: None,
729 token_encrypted: None,
730 }
731 }
732}
733
734fn default_ntfy_base_url() -> String {
735 "https://ntfy.sh".to_string()
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct BarkChannelConfig {
741 #[serde(default)]
742 pub enabled: bool,
743 #[serde(default = "default_bark_base_url")]
745 pub base_url: String,
746 #[serde(default, skip_serializing)]
752 pub device_key: Option<String>,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub device_key_encrypted: Option<String>,
756}
757
758impl Default for BarkChannelConfig {
759 fn default() -> Self {
760 Self {
761 enabled: false,
762 base_url: default_bark_base_url(),
763 device_key: None,
764 device_key_encrypted: None,
765 }
766 }
767}
768
769fn default_bark_base_url() -> String {
770 "https://api.day.app".to_string()
771}
772
773#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
778pub struct NotificationsConfig {
779 #[serde(default)]
780 pub desktop: DesktopChannelConfig,
781 #[serde(default)]
782 pub ntfy: NtfyChannelConfig,
783 #[serde(default)]
784 pub bark: BarkChannelConfig,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
791pub struct ConnectPlatformConfig {
792 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub id: Option<String>,
807 #[serde(rename = "type")]
811 pub platform_type: String,
812 #[serde(default, skip_serializing)]
818 pub token: Option<String>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub token_encrypted: Option<String>,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub app_id: Option<String>,
826 #[serde(default, skip_serializing)]
832 pub app_secret: Option<String>,
833 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub app_secret_encrypted: Option<String>,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub domain: Option<String>,
843 #[serde(default)]
848 pub allow_from: Vec<String>,
849 #[serde(default)]
853 pub admin_from: Vec<String>,
854}
855
856#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
860pub struct ConnectConfig {
861 #[serde(default)]
862 pub platforms: Vec<ConnectPlatformConfig>,
863}
864
865fn connect_config_is_empty(config: &ConnectConfig) -> bool {
866 config.platforms.is_empty()
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
876pub struct TrustedKey {
877 pub label: String,
879 pub algorithm: String,
881 pub public_key: String,
883}
884
885fn default_trusted_keys() -> Vec<TrustedKey> {
892 vec![
893 TrustedKey {
894 label: "nova (bigduu official)".to_string(),
895 algorithm: "ed25519".to_string(),
896 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
897 .to_string(),
898 },
899 TrustedKey {
900 label: "magpie (bigduu official)".to_string(),
901 algorithm: "ed25519".to_string(),
902 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
903 .to_string(),
904 },
905 ]
906}
907
908fn default_trusted_hosts() -> Vec<String> {
911 vec!["github.com/bigduu/".to_string()]
912}
913
914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
931pub struct PluginTrustConfig {
932 #[serde(default = "default_trusted_hosts")]
943 pub trusted_hosts: Vec<String>,
944 #[serde(default = "default_trusted_keys")]
947 pub trusted_keys: Vec<TrustedKey>,
948 #[serde(default)]
962 pub enforcement: PluginTrustEnforcement,
963}
964
965impl Default for PluginTrustConfig {
966 fn default() -> Self {
967 Self {
968 trusted_hosts: default_trusted_hosts(),
969 trusted_keys: default_trusted_keys(),
970 enforcement: PluginTrustEnforcement::default(),
971 }
972 }
973}
974
975impl PluginTrustConfig {
976 pub fn is_host_trusted(&self, url: &str) -> bool {
982 is_host_trusted(url, &self.trusted_hosts)
983 }
984
985 pub fn enforcement_is_off(&self) -> bool {
991 matches!(self.enforcement, PluginTrustEnforcement::Off)
992 }
993}
994
995#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1008#[serde(rename_all = "snake_case")]
1009pub enum PluginTrustEnforcement {
1010 #[default]
1015 Strict,
1016 Off,
1020}
1021
1022impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1023 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1024 where
1025 D: serde::Deserializer<'de>,
1026 {
1027 #[derive(Deserialize)]
1028 #[serde(untagged)]
1029 enum Repr {
1030 Bool(bool),
1031 Str(String),
1032 }
1033 match Repr::deserialize(deserializer)? {
1034 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1035 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1036 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1037 "strict" => Ok(PluginTrustEnforcement::Strict),
1038 "off" => Ok(PluginTrustEnforcement::Off),
1039 other => Err(serde::de::Error::custom(format!(
1040 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1041 \"off\""
1042 ))),
1043 },
1044 }
1045 }
1046}
1047
1048struct TrustedHostEntry<'a> {
1053 host: &'a str,
1054 path_prefix: &'a str,
1055}
1056
1057fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1061 match entry.find('/') {
1062 Some(index) => TrustedHostEntry {
1063 host: &entry[..index],
1064 path_prefix: &entry[index..],
1065 },
1066 None => TrustedHostEntry {
1067 host: entry,
1068 path_prefix: "",
1069 },
1070 }
1071}
1072
1073fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1085 if prefix.is_empty() || path == prefix {
1086 return true;
1087 }
1088 if prefix.ends_with('/') {
1089 return path.starts_with(prefix);
1090 }
1091 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1092}
1093
1094pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1118 let Ok(parsed) = url::Url::parse(url) else {
1119 return false;
1120 };
1121 if parsed.scheme() != "https" {
1122 return false;
1123 }
1124 let Some(host) = parsed.host_str() else {
1125 return false;
1126 };
1127 let host = host.to_ascii_lowercase();
1128 let path = parsed.path();
1129
1130 trusted_hosts.iter().any(|raw_entry| {
1131 let entry = raw_entry.trim().to_ascii_lowercase();
1132 let parsed_entry = parse_trusted_host_entry(&entry);
1133 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1134 })
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize)]
1142pub struct Config {
1143 #[serde(default)]
1145 pub http_proxy: String,
1146 #[serde(default)]
1148 pub https_proxy: String,
1149 #[serde(skip_serializing)]
1153 pub proxy_auth: Option<ProxyAuth>,
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 pub proxy_auth_encrypted: Option<String>,
1160 #[serde(default)]
1162 pub headless_auth: bool,
1163
1164 #[serde(default = "default_provider")]
1166 pub provider: String,
1167
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1170 pub defaults: Option<DefaultsConfig>,
1171
1172 #[serde(default)]
1174 pub providers: ProviderConfigs,
1175
1176 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1183 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1184
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1189 pub default_provider_instance: Option<String>,
1190
1191 #[serde(default)]
1193 pub server: ServerConfig,
1194
1195 #[serde(default)]
1199 pub keyword_masking: KeywordMaskingConfig,
1200
1201 #[serde(default)]
1205 pub anthropic_model_mapping: AnthropicModelMapping,
1206
1207 #[serde(default)]
1211 pub gemini_model_mapping: GeminiModelMapping,
1212
1213 #[serde(default)]
1218 pub hooks: HooksConfig,
1219
1220 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1224 pub tools: ToolsConfig,
1225
1226 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1231 pub skills: SkillsConfig,
1232
1233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1237 pub env_vars: Vec<EnvVarEntry>,
1238
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub default_work_area: Option<DefaultWorkAreaConfig>,
1242
1243 #[serde(default, skip_serializing_if = "Option::is_none")]
1245 pub access_control: Option<AccessControlConfig>,
1246
1247 #[serde(default)]
1249 pub features: FeatureFlags,
1250
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1253 pub memory: Option<MemoryConfig>,
1254
1255 #[serde(default)]
1263 pub subagents: SubagentsConfig,
1264
1265 #[serde(default)]
1270 pub run_budget: RunBudgetConfig,
1271
1272 #[serde(
1276 default,
1277 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1278 )]
1279 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1280
1281 #[serde(default, rename = "mcpServers", alias = "mcp")]
1287 pub mcp: bamboo_domain::mcp_config::McpConfig,
1288
1289 #[serde(default)]
1294 pub notifications: NotificationsConfig,
1295
1296 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1301 pub connect: ConnectConfig,
1302
1303 #[serde(default)]
1307 pub plugin_trust: PluginTrustConfig,
1308
1309 #[serde(default, flatten)]
1315 pub extra: BTreeMap<String, Value>,
1316
1317 #[serde(skip)]
1328 pub recovery_status: Option<ConfigRecoveryStatus>,
1329}
1330
1331#[derive(Debug, Clone, PartialEq, Serialize)]
1333#[serde(tag = "kind", rename_all = "snake_case")]
1334pub enum ConfigRecoverySource {
1335 Salvaged { fields: Vec<String> },
1340 Backup { generation: usize },
1343 Defaults,
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Serialize)]
1359pub struct ConfigRecoveryStatus {
1360 pub source: ConfigRecoverySource,
1362 pub quarantine_path: Option<PathBuf>,
1368 pub confirmed: bool,
1371}
1372
1373#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1377pub struct ProviderConfigs {
1378 #[serde(skip_serializing_if = "Option::is_none")]
1380 pub openai: Option<OpenAIConfig>,
1381 #[serde(skip_serializing_if = "Option::is_none")]
1383 pub anthropic: Option<AnthropicConfig>,
1384 #[serde(skip_serializing_if = "Option::is_none")]
1386 pub gemini: Option<GeminiConfig>,
1387 #[serde(skip_serializing_if = "Option::is_none")]
1389 pub copilot: Option<CopilotConfig>,
1390 #[serde(skip_serializing_if = "Option::is_none")]
1392 pub bodhi: Option<BodhiConfig>,
1393
1394 #[serde(default, flatten)]
1396 pub extra: BTreeMap<String, Value>,
1397}
1398
1399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1401pub struct FeatureFlags {
1402 #[serde(default)]
1404 pub provider_model_ref: bool,
1405 #[serde(default)]
1407 pub dynamic_model_routing: bool,
1408}
1409
1410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1414pub struct DefaultsConfig {
1415 pub chat: bamboo_domain::ProviderModelRef,
1416 #[serde(default, skip_serializing_if = "Option::is_none")]
1417 pub fast: Option<bamboo_domain::ProviderModelRef>,
1418 #[serde(default, skip_serializing_if = "Option::is_none")]
1419 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
1420 #[serde(default, skip_serializing_if = "Option::is_none")]
1421 pub vision: Option<bamboo_domain::ProviderModelRef>,
1422 #[serde(default, skip_serializing_if = "Option::is_none")]
1423 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
1424 #[serde(default, skip_serializing_if = "Option::is_none")]
1427 pub planning: Option<bamboo_domain::ProviderModelRef>,
1428 #[serde(default, skip_serializing_if = "Option::is_none")]
1431 pub search: Option<bamboo_domain::ProviderModelRef>,
1432 #[serde(default, skip_serializing_if = "Option::is_none")]
1435 pub code_review: Option<bamboo_domain::ProviderModelRef>,
1436 #[serde(
1439 default,
1440 skip_serializing_if = "Option::is_none",
1441 alias = "sub_session"
1442 )]
1443 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
1444 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1448 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
1449}
1450
1451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1453pub struct HooksConfig {
1454 #[serde(default)]
1456 pub image_fallback: ImageFallbackHookConfig,
1457}
1458
1459#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1466pub struct RequestOverridesConfig {
1467 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
1469 pub common: RequestScopeOverride,
1470 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1472 pub endpoints: BTreeMap<String, RequestScopeOverride>,
1473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1475 pub rules: Vec<ModelRequestRule>,
1476}
1477
1478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1480pub struct ModelRequestRule {
1481 pub model_pattern: String,
1483 #[serde(default, skip_serializing_if = "Option::is_none")]
1485 pub endpoint: Option<String>,
1486 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
1488 pub scope: RequestScopeOverride,
1489}
1490
1491#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1493pub struct RequestScopeOverride {
1494 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1496 pub headers: BTreeMap<String, TemplateExpr>,
1497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1499 pub body_patch: Vec<BodyPatch>,
1500}
1501
1502impl RequestScopeOverride {
1503 pub fn is_empty(&self) -> bool {
1504 self.headers.is_empty() && self.body_patch.is_empty()
1505 }
1506}
1507
1508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1510pub struct BodyPatch {
1511 pub path: String,
1513 #[serde(default)]
1515 pub op: BodyPatchOp,
1516 #[serde(default, skip_serializing_if = "Option::is_none")]
1518 pub value: Option<PatchValue>,
1519}
1520
1521#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1523#[serde(rename_all = "snake_case")]
1524pub enum BodyPatchOp {
1525 #[default]
1526 Set,
1527 Remove,
1528}
1529
1530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1532#[serde(untagged)]
1533pub enum PatchValue {
1534 Template(TemplateExpr),
1535 Json(Value),
1536}
1537
1538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1540#[serde(untagged)]
1541pub enum TemplateExpr {
1542 Literal(String),
1544 Structured(TemplateExprSpec),
1546}
1547
1548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1550#[serde(tag = "type", rename_all = "snake_case")]
1551pub enum TemplateExprSpec {
1552 Literal { value: String },
1554 EnvRef {
1556 name: String,
1557 #[serde(default, skip_serializing_if = "Option::is_none")]
1558 fallback: Option<String>,
1559 },
1560 Generated { generator: GeneratedValue },
1562 Format { template: String },
1564}
1565
1566#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1568#[serde(rename_all = "snake_case")]
1569pub enum GeneratedValue {
1570 Uuid,
1571 UnixMs,
1572}
1573
1574#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1576pub struct ToolsConfig {
1577 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1579 pub disabled: Vec<String>,
1580}
1581
1582impl ToolsConfig {
1583 fn is_empty(&self) -> bool {
1584 self.disabled.is_empty()
1585 }
1586}
1587
1588#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1590pub struct SkillsConfig {
1591 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1593 pub disabled: Vec<String>,
1594}
1595
1596impl SkillsConfig {
1597 fn is_empty(&self) -> bool {
1598 self.disabled.is_empty()
1599 }
1600}
1601
1602#[derive(Debug, Clone, Serialize, Deserialize)]
1607pub struct ImageFallbackHookConfig {
1608 #[serde(default = "default_true_hooks")]
1609 pub enabled: bool,
1610
1611 #[serde(default = "default_image_fallback_mode")]
1613 pub mode: String,
1614}
1615
1616impl Default for ImageFallbackHookConfig {
1617 fn default() -> Self {
1618 Self {
1619 enabled: default_true_hooks(),
1620 mode: default_image_fallback_mode(),
1621 }
1622 }
1623}
1624
1625fn default_image_fallback_mode() -> String {
1626 "placeholder".to_string()
1627}
1628
1629fn default_true_hooks() -> bool {
1630 false
1633}
1634
1635#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1647pub struct OpenAIConfig {
1648 #[serde(default, skip_serializing)]
1652 pub api_key: String,
1653 #[serde(default, skip_serializing_if = "Option::is_none")]
1655 pub api_key_encrypted: Option<String>,
1656 #[serde(skip)]
1661 pub api_key_from_env: bool,
1662 #[serde(skip_serializing_if = "Option::is_none")]
1664 pub base_url: Option<String>,
1665 #[serde(skip_serializing_if = "Option::is_none")]
1667 pub model: Option<String>,
1668 #[serde(default, skip_serializing_if = "Option::is_none")]
1671 pub fast_model: Option<String>,
1672 #[serde(default, skip_serializing_if = "Option::is_none")]
1675 pub vision_model: Option<String>,
1676 #[serde(skip_serializing_if = "Option::is_none")]
1678 pub reasoning_effort: Option<ReasoningEffort>,
1679
1680 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1687 pub responses_only_models: Vec<String>,
1688 #[serde(default, skip_serializing_if = "Option::is_none")]
1690 pub request_overrides: Option<RequestOverridesConfig>,
1691
1692 #[serde(default, flatten)]
1694 pub extra: BTreeMap<String, Value>,
1695}
1696
1697#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1709pub struct AnthropicConfig {
1710 #[serde(default, skip_serializing)]
1714 pub api_key: String,
1715 #[serde(default, skip_serializing_if = "Option::is_none")]
1717 pub api_key_encrypted: Option<String>,
1718 #[serde(skip)]
1723 pub api_key_from_env: bool,
1724 #[serde(skip_serializing_if = "Option::is_none")]
1726 pub base_url: Option<String>,
1727 #[serde(skip_serializing_if = "Option::is_none")]
1729 pub model: Option<String>,
1730 #[serde(default, skip_serializing_if = "Option::is_none")]
1733 pub fast_model: Option<String>,
1734 #[serde(default, skip_serializing_if = "Option::is_none")]
1737 pub vision_model: Option<String>,
1738 #[serde(skip_serializing_if = "Option::is_none")]
1740 pub max_tokens: Option<u32>,
1741 #[serde(skip_serializing_if = "Option::is_none")]
1743 pub reasoning_effort: Option<ReasoningEffort>,
1744 #[serde(default, skip_serializing_if = "Option::is_none")]
1746 pub request_overrides: Option<RequestOverridesConfig>,
1747
1748 #[serde(default, skip_serializing_if = "Option::is_none")]
1765 pub thinking_replay_always: Option<bool>,
1766
1767 #[serde(default, flatten)]
1769 pub extra: BTreeMap<String, Value>,
1770}
1771
1772#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1783pub struct GeminiConfig {
1784 #[serde(default, skip_serializing)]
1788 pub api_key: String,
1789 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 pub api_key_encrypted: Option<String>,
1792 #[serde(skip)]
1797 pub api_key_from_env: bool,
1798 #[serde(skip_serializing_if = "Option::is_none")]
1800 pub base_url: Option<String>,
1801 #[serde(skip_serializing_if = "Option::is_none")]
1803 pub model: Option<String>,
1804 #[serde(default, skip_serializing_if = "Option::is_none")]
1807 pub fast_model: Option<String>,
1808 #[serde(default, skip_serializing_if = "Option::is_none")]
1811 pub vision_model: Option<String>,
1812 #[serde(skip_serializing_if = "Option::is_none")]
1814 pub reasoning_effort: Option<ReasoningEffort>,
1815 #[serde(default, skip_serializing_if = "Option::is_none")]
1817 pub request_overrides: Option<RequestOverridesConfig>,
1818
1819 #[serde(default, flatten)]
1821 pub extra: BTreeMap<String, Value>,
1822}
1823
1824#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1836pub struct CopilotConfig {
1837 #[serde(default)]
1839 pub enabled: bool,
1840 #[serde(default)]
1842 pub headless_auth: bool,
1843 #[serde(skip_serializing_if = "Option::is_none")]
1845 pub model: Option<String>,
1846 #[serde(default, skip_serializing_if = "Option::is_none")]
1849 pub fast_model: Option<String>,
1850 #[serde(default, skip_serializing_if = "Option::is_none")]
1853 pub vision_model: Option<String>,
1854 #[serde(skip_serializing_if = "Option::is_none")]
1856 pub reasoning_effort: Option<ReasoningEffort>,
1857
1858 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1867 pub responses_only_models: Vec<String>,
1868 #[serde(default, skip_serializing_if = "Option::is_none")]
1870 pub request_overrides: Option<RequestOverridesConfig>,
1871
1872 #[serde(default, flatten)]
1874 pub extra: BTreeMap<String, Value>,
1875}
1876
1877#[derive(Debug, Clone, Serialize, Deserialize)]
1882pub struct BodhiConfig {
1883 #[serde(default, skip_serializing)]
1885 pub api_key: String,
1886 #[serde(default, skip_serializing_if = "Option::is_none")]
1888 pub api_key_encrypted: Option<String>,
1889 #[serde(skip_serializing_if = "Option::is_none")]
1891 pub base_url: Option<String>,
1892 #[serde(skip_serializing_if = "Option::is_none")]
1894 pub target_provider: Option<String>,
1895 #[serde(skip_serializing_if = "Option::is_none")]
1897 pub reasoning_effort: Option<ReasoningEffort>,
1898
1899 #[serde(default, flatten)]
1901 pub extra: BTreeMap<String, Value>,
1902}
1903
1904fn default_provider() -> String {
1906 "anthropic".to_string()
1907}
1908
1909#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1941pub struct ProviderInstanceConfig {
1942 pub provider_type: String,
1947
1948 #[serde(default, skip_serializing_if = "Option::is_none")]
1950 pub label: Option<String>,
1951
1952 #[serde(default, skip_serializing)]
1954 pub api_key: String,
1955
1956 #[serde(default, skip_serializing_if = "Option::is_none")]
1959 pub api_key_encrypted: Option<String>,
1960
1961 #[serde(default, skip_serializing_if = "Option::is_none")]
1963 pub base_url: Option<String>,
1964
1965 #[serde(default, skip_serializing_if = "Option::is_none")]
1967 pub model: Option<String>,
1968
1969 #[serde(default, skip_serializing_if = "Option::is_none")]
1971 pub fast_model: Option<String>,
1972
1973 #[serde(default, skip_serializing_if = "Option::is_none")]
1975 pub vision_model: Option<String>,
1976
1977 #[serde(default, skip_serializing_if = "Option::is_none")]
1979 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
1980
1981 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1983 pub responses_only_models: Vec<String>,
1984
1985 #[serde(default, skip_serializing_if = "Option::is_none")]
1987 pub request_overrides: Option<RequestOverridesConfig>,
1988
1989 #[serde(default = "default_true")]
1992 pub enabled: bool,
1993
1994 #[serde(default, flatten)]
1996 pub extra: BTreeMap<String, Value>,
1997}
1998
1999fn default_true() -> bool {
2000 true
2001}
2002
2003fn default_port() -> u16 {
2005 9562
2006}
2007
2008fn default_bind() -> String {
2010 "127.0.0.1".to_string()
2011}
2012
2013fn default_workers() -> usize {
2015 10
2016}
2017
2018fn default_data_dir() -> PathBuf {
2020 super::paths::bamboo_dir()
2021}
2022
2023#[derive(Debug, Clone, Serialize, Deserialize)]
2025pub struct ServerConfig {
2026 #[serde(default = "default_port")]
2028 pub port: u16,
2029
2030 #[serde(default = "default_bind")]
2032 pub bind: String,
2033
2034 pub static_dir: Option<PathBuf>,
2036
2037 #[serde(default = "default_workers")]
2039 pub workers: usize,
2040
2041 #[serde(default, skip_serializing_if = "Option::is_none")]
2049 pub tls: Option<TlsConfig>,
2050
2051 #[serde(default, flatten)]
2053 pub extra: BTreeMap<String, Value>,
2054}
2055
2056impl Default for ServerConfig {
2057 fn default() -> Self {
2058 Self {
2059 port: default_port(),
2060 bind: default_bind(),
2061 static_dir: None,
2062 workers: default_workers(),
2063 tls: None,
2064 extra: BTreeMap::new(),
2065 }
2066 }
2067}
2068
2069#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2075pub struct TlsConfig {
2076 pub cert_file: PathBuf,
2078 pub key_file: PathBuf,
2080}
2081
2082#[derive(Debug, Clone, Serialize, Deserialize)]
2084pub struct ProxyAuth {
2085 pub username: String,
2087 pub password: String,
2089}
2090
2091fn parse_bool_env(value: &str) -> bool {
2095 matches!(
2096 value.trim().to_ascii_lowercase().as_str(),
2097 "1" | "true" | "yes" | "y" | "on"
2098 )
2099}
2100
2101fn expand_user_path(value: &str) -> PathBuf {
2102 let trimmed = value.trim();
2103 if let Some(rest) = trimmed.strip_prefix("~/") {
2104 if let Some(home) = dirs::home_dir() {
2105 return home.join(rest);
2106 }
2107 }
2108 PathBuf::from(trimmed)
2109}
2110
2111impl Default for Config {
2112 fn default() -> Self {
2113 Self::create_default()
2122 }
2123}
2124
2125#[derive(Debug, Clone, PartialEq, Eq)]
2127pub struct PromptSafeEnvVarEntry {
2128 pub name: String,
2129 pub secret: bool,
2130 pub description: Option<String>,
2131}
2132
2133static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
2137
2138static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
2139
2140fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
2141 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
2142}
2143
2144fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
2145 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
2146}
2147
2148impl Config {
2149 pub fn new() -> Self {
2168 Self::from_data_dir(None)
2169 }
2170
2171 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
2180 let data_dir = data_dir
2182 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
2183 .unwrap_or_else(default_data_dir);
2184
2185 let config_path = data_dir.join("config.json");
2186
2187 let mut config = if config_path.exists() {
2188 if let Ok(content) = std::fs::read_to_string(&config_path) {
2189 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
2190 tracing::warn!(
2200 "Failed to parse config.json ({}); quarantining it and attempting recovery",
2201 e
2202 );
2203 let quarantine_path = quarantine_corrupt_config(&config_path);
2204 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
2205 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
2206 .or_else(|| {
2207 Self::load_backup(&data_dir).map(|(cfg, generation)| {
2208 (cfg, ConfigRecoverySource::Backup { generation })
2209 })
2210 })
2211 .unwrap_or_else(|| {
2212 tracing::warn!(
2213 "Could not salvage and no usable config.json.bak; using defaults"
2214 );
2215 (Self::create_default(), ConfigRecoverySource::Defaults)
2216 });
2217 recovered.recovery_status = Some(ConfigRecoveryStatus {
2218 source,
2219 quarantine_path,
2220 confirmed: false,
2221 });
2222 recovered
2223 })
2224 } else {
2225 Self::create_default()
2226 }
2227 } else {
2228 Self::create_default()
2229 };
2230
2231 config.hydrate_proxy_auth_from_encrypted();
2233 config.hydrate_provider_api_keys_from_encrypted();
2235 config.hydrate_provider_instance_api_keys_from_encrypted();
2237 config.hydrate_mcp_secrets_from_encrypted();
2239 config.hydrate_env_vars_from_encrypted();
2241 config.hydrate_cluster_fabric_from_encrypted();
2243 config.hydrate_broker_token_from_encrypted();
2245 config.hydrate_notifications_from_encrypted();
2247 config.merge_connect_config(&data_dir);
2252 scrub_legacy_connect_from_config_backups(&data_dir);
2262 config.hydrate_connect_platform_tokens_from_encrypted();
2264 config.normalize_tool_settings();
2265 config.normalize_skill_settings();
2266 config.normalize_plugin_trust_settings();
2267
2268 config.extra.remove("data_dir");
2271
2272 if apply_env {
2276 config.apply_env_overrides();
2277 }
2278
2279 if publish {
2283 config.publish_env_vars();
2284 }
2285
2286 config
2287 }
2288
2289 fn apply_env_overrides(&mut self) {
2293 if let Ok(port) = std::env::var("BAMBOO_PORT") {
2294 if let Ok(port) = port.parse() {
2295 self.server.port = port;
2296 }
2297 }
2298
2299 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
2300 self.server.bind = bind;
2301 }
2302
2303 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
2305 self.provider = provider;
2306 }
2307
2308 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
2309 self.headless_auth = parse_bool_env(&headless);
2310 }
2311
2312 if let Ok(project_prompt_injection) =
2313 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
2314 {
2315 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2316 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
2317 }
2318
2319 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
2320 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2321 memory.relevant_recall = parse_bool_env(&relevant_recall);
2322 }
2323
2324 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
2325 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2326 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
2327 }
2328
2329 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
2330 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2331 memory.project_first_dream = parse_bool_env(&project_first_dream);
2332 }
2333
2334 if let Ok(key) = std::env::var("BAMBOO_OPENAI_API_KEY") {
2341 let key = key.trim();
2342 if !key.is_empty() {
2343 let openai = self
2344 .providers
2345 .openai
2346 .get_or_insert_with(OpenAIConfig::default);
2347 openai.api_key = key.to_string();
2348 openai.api_key_from_env = true;
2349 }
2350 }
2351 if let Ok(key) = std::env::var("BAMBOO_ANTHROPIC_API_KEY") {
2352 let key = key.trim();
2353 if !key.is_empty() {
2354 let anthropic = self
2355 .providers
2356 .anthropic
2357 .get_or_insert_with(AnthropicConfig::default);
2358 anthropic.api_key = key.to_string();
2359 anthropic.api_key_from_env = true;
2360 }
2361 }
2362 if let Ok(key) = std::env::var("BAMBOO_GEMINI_API_KEY") {
2363 let key = key.trim();
2364 if !key.is_empty() {
2365 let gemini = self
2366 .providers
2367 .gemini
2368 .get_or_insert_with(GeminiConfig::default);
2369 gemini.api_key = key.to_string();
2370 gemini.api_key_from_env = true;
2371 }
2372 }
2373 }
2374
2375 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
2381 Self::from_data_dir_impl(data_dir, true, true)
2382 }
2383
2384 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
2388 Self::from_data_dir_impl(data_dir, false, true)
2389 }
2390
2391 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
2398 Self::from_data_dir_impl(data_dir, false, false)
2399 }
2400
2401 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
2429 let connect_path = data_dir.join("connect.json");
2430 match std::fs::read_to_string(&connect_path) {
2431 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
2432 Ok(connect) => {
2433 let legacy_key_present = !connect_config_is_empty(&self.connect);
2434 if legacy_key_present {
2435 tracing::warn!(
2436 "config.json still has a legacy `connect` key alongside \
2437 connect.json; connect.json takes precedence — dropping the \
2438 stale key from config.json now"
2439 );
2440 }
2441 self.connect = connect;
2442 if legacy_key_present {
2443 strip_legacy_connect_key_from_config_json(data_dir);
2444 }
2445 }
2446 Err(e) => {
2447 tracing::error!(
2448 "Failed to parse {:?} ({}); continuing with an empty (inert) \
2449 connect config instead of falling back to a legacy config.json copy",
2450 connect_path,
2451 e
2452 );
2453 quarantine_corrupt_connect(&connect_path);
2454 self.connect = ConnectConfig::default();
2455 }
2456 },
2457 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2458 if !connect_config_is_empty(&self.connect) {
2459 tracing::info!(
2460 "Migrating legacy `connect` config from config.json to a \
2461 standalone connect.json"
2462 );
2463 strip_legacy_connect_key_from_config_json(data_dir);
2467 if let Err(e) = save_connect_config(&self.connect, data_dir) {
2468 tracing::error!("Failed to write connect.json during migration: {}", e);
2469 }
2470 }
2471 }
2472 Err(e) => {
2473 tracing::error!(
2474 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
2475 connect_path,
2476 e
2477 );
2478 self.connect = ConnectConfig::default();
2479 }
2480 }
2481 }
2482
2483 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
2486 serde_json::from_str::<Config>(content).map(|mut config| {
2487 config.hydrate_proxy_auth_from_encrypted();
2488 config.hydrate_provider_api_keys_from_encrypted();
2489 config.hydrate_provider_instance_api_keys_from_encrypted();
2490 config.hydrate_mcp_secrets_from_encrypted();
2491 config.hydrate_env_vars_from_encrypted();
2492 config.hydrate_cluster_fabric_from_encrypted();
2493 config.hydrate_broker_token_from_encrypted();
2494 config.hydrate_notifications_from_encrypted();
2495 config.hydrate_connect_platform_tokens_from_encrypted();
2496 config.normalize_tool_settings();
2497 config.normalize_skill_settings();
2498 config
2499 })
2500 }
2501
2502 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
2508 let config_path = data_dir.join("config.json");
2509 for gen in 0..BAK_GENERATIONS {
2510 let backup = backup_path_for(&config_path, gen);
2511 let Ok(content) = std::fs::read_to_string(&backup) else {
2512 continue;
2513 };
2514 match Self::parse_and_hydrate(&content) {
2515 Ok(config) => {
2516 tracing::info!("Recovered configuration from {:?}", backup);
2517 return Some((config, gen));
2518 }
2519 Err(e) => {
2520 tracing::warn!(
2521 "Backup {:?} is unparseable ({}); trying an older generation",
2522 backup,
2523 e
2524 );
2525 }
2526 }
2527 }
2528 None
2529 }
2530
2531 const SALVAGE_MAX_KEYS: usize = 512;
2537
2538 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
2565 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
2568 let corrupt_obj = corrupt.as_object()?;
2569 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
2570 tracing::warn!(
2571 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
2572 corrupt_obj.len(),
2573 Self::SALVAGE_MAX_KEYS
2574 );
2575 return None;
2576 }
2577
2578 let mut base = Self::load_backup(data_dir)
2582 .and_then(|(backup, _generation)| serde_json::to_value(backup).ok())
2583 .or_else(|| serde_json::to_value(Self::create_default()).ok())?;
2584 let base_obj = base.as_object_mut()?;
2585
2586 let mut salvaged: Vec<String> = Vec::new();
2587 for (key, value) in corrupt_obj {
2588 let previous = base_obj.insert(key.clone(), value.clone());
2589 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
2593 salvaged.push(key.clone());
2594 } else {
2595 match previous {
2596 Some(prev) => {
2597 base_obj.insert(key.clone(), prev);
2598 }
2599 None => {
2600 base_obj.remove(key);
2601 }
2602 }
2603 }
2604 }
2605
2606 tracing::warn!(
2607 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
2608 last-known-good/default value",
2609 salvaged.len(),
2610 salvaged.join(", ")
2611 );
2612
2613 let rebuilt = serde_json::to_string(&base).ok()?;
2617 Self::parse_and_hydrate(&rebuilt)
2618 .ok()
2619 .map(|config| (config, salvaged))
2620 }
2621
2622 pub fn get_model(&self) -> Option<String> {
2630 if self.features.provider_model_ref {
2631 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
2632 return Some(model_ref.model.clone());
2633 }
2634 }
2635 match self.provider.as_str() {
2636 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
2637 "anthropic" => self
2638 .providers
2639 .anthropic
2640 .as_ref()
2641 .and_then(|c| c.model.clone()),
2642 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
2643 "copilot" => Some(
2644 self.providers
2645 .copilot
2646 .as_ref()
2647 .and_then(|c| c.model.clone())
2648 .unwrap_or_else(|| "gpt-4o".to_string()),
2649 ),
2650 _ => None,
2651 }
2652 }
2653
2654 pub fn get_fast_model(&self) -> Option<String> {
2662 if self.features.provider_model_ref {
2663 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
2664 return Some(model_ref.model.clone());
2665 }
2666 }
2667 let fast = match self.provider.as_str() {
2668 "openai" => self
2669 .providers
2670 .openai
2671 .as_ref()
2672 .and_then(|c| c.fast_model.clone()),
2673 "anthropic" => self
2674 .providers
2675 .anthropic
2676 .as_ref()
2677 .and_then(|c| c.fast_model.clone()),
2678 "gemini" => self
2679 .providers
2680 .gemini
2681 .as_ref()
2682 .and_then(|c| c.fast_model.clone()),
2683 "copilot" => self
2684 .providers
2685 .copilot
2686 .as_ref()
2687 .and_then(|c| c.fast_model.clone()),
2688 _ => None,
2689 };
2690 fast.or_else(|| self.get_model())
2691 }
2692
2693 pub fn get_task_summary_model(&self) -> Option<String> {
2701 if self.features.provider_model_ref {
2702 if let Some(model_ref) = self
2703 .defaults
2704 .as_ref()
2705 .and_then(|d| d.task_summary.as_ref())
2706 .or_else(|| {
2707 self.defaults
2708 .as_ref()
2709 .and_then(|d| d.memory_background.as_ref())
2710 })
2711 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
2712 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
2713 {
2714 return Some(model_ref.model.clone());
2715 }
2716 }
2717
2718 self.get_memory_background_model()
2719 .or_else(|| self.get_model())
2720 }
2721
2722 pub fn get_memory_background_model(&self) -> Option<String> {
2734 if self.features.provider_model_ref {
2735 if let Some(model_ref) = self
2736 .defaults
2737 .as_ref()
2738 .and_then(|d| d.memory_background.as_ref())
2739 {
2740 return Some(model_ref.model.clone());
2741 }
2742 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
2743 return Some(model_ref.model.clone());
2744 }
2745 }
2746 let configured = self
2747 .memory
2748 .as_ref()
2749 .and_then(|memory| memory.background_model.as_ref())
2750 .map(|value| value.trim())
2751 .filter(|value| !value.is_empty())
2752 .map(ToString::to_string);
2753 configured.or_else(|| match self.provider.as_str() {
2754 "openai" => self
2755 .providers
2756 .openai
2757 .as_ref()
2758 .and_then(|c| c.fast_model.clone()),
2759 "anthropic" => self
2760 .providers
2761 .anthropic
2762 .as_ref()
2763 .and_then(|c| c.fast_model.clone()),
2764 "gemini" => self
2765 .providers
2766 .gemini
2767 .as_ref()
2768 .and_then(|c| c.fast_model.clone()),
2769 "copilot" => self
2770 .providers
2771 .copilot
2772 .as_ref()
2773 .and_then(|c| c.fast_model.clone()),
2774 _ => None,
2775 })
2776 }
2777
2778 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
2786 let raw = self
2787 .default_work_area
2788 .as_ref()
2789 .and_then(|config| config.path.as_ref())
2790 .map(|value| value.trim())
2791 .filter(|value| !value.is_empty())?;
2792
2793 let candidate = expand_user_path(raw);
2794 if candidate.is_absolute() {
2795 let canonical = std::fs::canonicalize(&candidate).ok();
2796 return canonical
2797 .as_ref()
2798 .filter(|path| path.is_dir())
2799 .map(|_| candidate.clone())
2800 .or_else(|| candidate.is_dir().then_some(candidate));
2801 }
2802
2803 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
2804 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
2805 canonical
2806 .as_ref()
2807 .filter(|path| path.is_dir())
2808 .map(|_| from_bamboo_dir.clone())
2809 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
2810 .or_else(|| candidate.is_dir().then_some(candidate))
2811 }
2812
2813 pub fn get_vision_model(&self) -> Option<String> {
2818 let vision = match self.provider.as_str() {
2819 "openai" => self
2820 .providers
2821 .openai
2822 .as_ref()
2823 .and_then(|c| c.vision_model.clone()),
2824 "anthropic" => self
2825 .providers
2826 .anthropic
2827 .as_ref()
2828 .and_then(|c| c.vision_model.clone()),
2829 "gemini" => self
2830 .providers
2831 .gemini
2832 .as_ref()
2833 .and_then(|c| c.vision_model.clone()),
2834 "copilot" => self
2835 .providers
2836 .copilot
2837 .as_ref()
2838 .and_then(|c| c.vision_model.clone()),
2839 _ => None,
2840 };
2841 vision.or_else(|| self.get_model())
2842 }
2843
2844 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
2846 self.reasoning_effort_for_key(&self.provider)
2847 }
2848
2849 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
2859 let trimmed = key.trim();
2860 if trimmed.is_empty() {
2861 return None;
2862 }
2863
2864 if let Some(instance) = self.provider_instances.get(trimmed) {
2866 return instance.reasoning_effort;
2867 }
2868
2869 match trimmed {
2871 "openai" => self
2872 .providers
2873 .openai
2874 .as_ref()
2875 .and_then(|c| c.reasoning_effort),
2876 "anthropic" => self
2877 .providers
2878 .anthropic
2879 .as_ref()
2880 .and_then(|c| c.reasoning_effort),
2881 "gemini" => self
2882 .providers
2883 .gemini
2884 .as_ref()
2885 .and_then(|c| c.reasoning_effort),
2886 "copilot" => self
2887 .providers
2888 .copilot
2889 .as_ref()
2890 .and_then(|c| c.reasoning_effort),
2891 "bodhi" => self
2892 .providers
2893 .bodhi
2894 .as_ref()
2895 .and_then(|c| c.reasoning_effort),
2896 _ => None,
2897 }
2898 }
2899
2900 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
2902 self.tools
2903 .disabled
2904 .iter()
2905 .map(|name| name.trim())
2906 .filter(|name| !name.is_empty())
2907 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
2908 .collect()
2909 }
2910
2911 pub fn normalize_tool_settings(&mut self) {
2913 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
2914 }
2915
2916 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
2918 self.skills
2919 .disabled
2920 .iter()
2921 .map(|id| id.trim())
2922 .filter(|id| !id.is_empty())
2923 .map(|id| id.to_string())
2924 .collect()
2925 }
2926
2927 pub fn normalize_skill_settings(&mut self) {
2929 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
2930 }
2931
2932 pub fn normalize_plugin_trust_settings(&mut self) {
2940 self.plugin_trust.trusted_hosts = self
2941 .plugin_trust
2942 .trusted_hosts
2943 .iter()
2944 .map(|entry| entry.trim().to_ascii_lowercase())
2945 .filter(|entry| !entry.is_empty())
2946 .collect();
2947 }
2948
2949 pub fn effective_default_provider(&self) -> &str {
2954 self.default_provider_instance
2955 .as_deref()
2956 .unwrap_or(&self.provider)
2957 }
2958
2959 pub fn has_provider_instances(&self) -> bool {
2961 !self.provider_instances.is_empty()
2962 }
2963
2964 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
2966 self.env_vars
2967 .iter()
2968 .filter(|e| !e.value.trim().is_empty())
2969 .map(|e| (e.name.clone(), e.value.clone()))
2970 .collect()
2971 }
2972
2973 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
2974 self.env_vars
2975 .iter()
2976 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
2977 .map(|entry| PromptSafeEnvVarEntry {
2978 name: entry.name.clone(),
2979 secret: entry.secret,
2980 description: entry
2981 .description
2982 .as_ref()
2983 .map(|value| value.trim().to_string())
2984 .filter(|value| !value.is_empty()),
2985 })
2986 .collect()
2987 }
2988
2989 pub fn publish_env_vars(&self) {
2991 let map = self.env_vars_as_map();
2992 let mut env_guard = env_vars_cache().write().recover_poison();
2993 *env_guard = map;
2994
2995 let prompt_safe = self.prompt_safe_env_vars();
2996 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
2997 *prompt_guard = prompt_safe;
2998 }
2999
3000 pub fn current_env_vars() -> HashMap<String, String> {
3002 env_vars_cache().read().recover_poison().clone()
3003 }
3004
3005 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
3007 prompt_safe_env_vars_cache().read().recover_poison().clone()
3008 }
3009
3010 fn create_default() -> Self {
3012 Config {
3013 http_proxy: String::new(),
3014 https_proxy: String::new(),
3015 proxy_auth: None,
3016 proxy_auth_encrypted: None,
3017 headless_auth: false,
3018 subagents: SubagentsConfig::default(),
3019 run_budget: RunBudgetConfig::default(),
3020 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
3021 provider: default_provider(),
3022 providers: ProviderConfigs::default(),
3023 provider_instances: HashMap::new(),
3024 default_provider_instance: None,
3025 server: ServerConfig::default(),
3026 keyword_masking: KeywordMaskingConfig::default(),
3027 anthropic_model_mapping: AnthropicModelMapping::default(),
3028 gemini_model_mapping: GeminiModelMapping::default(),
3029 hooks: HooksConfig::default(),
3030 tools: ToolsConfig::default(),
3031 skills: SkillsConfig::default(),
3032 env_vars: Vec::new(),
3033 default_work_area: None,
3034 access_control: None,
3035 features: FeatureFlags::default(),
3036 defaults: None,
3037 memory: None,
3038 mcp: bamboo_domain::mcp_config::McpConfig::default(),
3039 notifications: NotificationsConfig::default(),
3040 connect: ConnectConfig::default(),
3041 plugin_trust: PluginTrustConfig::default(),
3042 extra: BTreeMap::new(),
3043 recovery_status: None,
3044 }
3045 }
3046
3047 pub fn server_addr(&self) -> String {
3049 format!("{}:{}", self.server.bind, self.server.port)
3050 }
3051
3052 pub fn save(&self) -> Result<()> {
3054 self.save_to_dir(default_data_dir())
3055 }
3056
3057 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
3061 self.recovery_status.as_ref()
3062 }
3063
3064 pub fn confirm_recovery(&mut self) {
3070 if let Some(status) = self.recovery_status.as_mut() {
3071 status.confirmed = true;
3072 }
3073 }
3074
3075 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
3083 if self.recovery_status.is_none() {
3084 anyhow::bail!("No pending config-corruption recovery to confirm");
3085 }
3086 self.confirm_recovery();
3087 self.save_to_dir(data_dir)?;
3088 self.recovery_status = None;
3089 Ok(())
3090 }
3091
3092 pub fn assign_connect_platform_ids(&mut self) {
3108 for platform in &mut self.connect.platforms {
3109 if platform.id.is_none() {
3110 platform.id = Some(uuid::Uuid::new_v4().to_string());
3111 }
3112 }
3113 }
3114
3115 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
3126 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
3127 anyhow::bail!(
3128 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
3129 has not been confirmed; the corrupt original is preserved at {:?}. Call \
3130 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
3131 status.source,
3132 status.quarantine_path,
3133 );
3134 }
3135
3136 let path = data_dir.join("config.json");
3137
3138 if let Some(parent) = path.parent() {
3139 std::fs::create_dir_all(parent)
3140 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
3141 }
3142
3143 let mut to_save = self.clone();
3144 to_save.extra.remove("data_dir");
3146 to_save.extra.remove("model");
3148 to_save.refresh_encrypted_secrets()?;
3151 to_save.sanitize_env_vars_for_disk();
3152 to_save.sanitize_cluster_fabric_for_disk();
3153 to_save.assign_connect_platform_ids();
3154 to_save.normalize_tool_settings();
3155 to_save.normalize_skill_settings();
3156
3157 let mut config_value =
3167 serde_json::to_value(&to_save).context("Failed to serialize config to JSON")?;
3168 if let Some(obj) = config_value.as_object_mut() {
3169 obj.remove("connect");
3170 }
3171 let content = serde_json::to_string_pretty(&config_value)
3172 .context("Failed to serialize config to JSON")?;
3173
3174 if path.exists()
3181 && std::fs::read_to_string(&path)
3182 .ok()
3183 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
3184 {
3185 rotate_backups(&path, BAK_GENERATIONS);
3189 let backup = backup_path_for(&path, 0);
3190 if let Err(e) = std::fs::copy(&path, &backup) {
3191 tracing::warn!("Failed to back up config.json before save: {}", e);
3192 }
3193 }
3194
3195 write_atomic(&path, content.as_bytes())
3196 .with_context(|| format!("Failed to write config file: {:?}", path))?;
3197
3198 save_connect_config(&to_save.connect, &data_dir)?;
3199
3200 Ok(())
3201 }
3202}
3203
3204fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
3214 let path = data_dir.join("connect.json");
3215 if connect_config_is_empty(connect) && !path.exists() {
3216 return Ok(());
3217 }
3218
3219 if path.exists() {
3220 let backup = path.with_extension("json.bak");
3221 if let Err(e) = std::fs::copy(&path, &backup) {
3222 tracing::warn!("Failed to back up connect.json before save: {}", e);
3223 }
3224 }
3225
3226 let content = serde_json::to_string_pretty(connect)
3227 .context("Failed to serialize connect config to JSON")?;
3228 write_atomic(&path, content.as_bytes())
3229 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
3230 Ok(())
3231}
3232
3233fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
3251 let config_path = data_dir.join("config.json");
3252 let content = match std::fs::read_to_string(&config_path) {
3253 Ok(content) => content,
3254 Err(e) => {
3255 tracing::error!(
3256 "Failed to read config.json to strip legacy `connect` key: {}",
3257 e
3258 );
3259 return;
3260 }
3261 };
3262 let mut value: serde_json::Value = match serde_json::from_str(&content) {
3263 Ok(value) => value,
3264 Err(e) => {
3265 tracing::error!(
3266 "Failed to parse config.json to strip legacy `connect` key: {}",
3267 e
3268 );
3269 return;
3270 }
3271 };
3272 let Some(obj) = value.as_object_mut() else {
3273 return;
3274 };
3275 if obj.remove("connect").is_none() {
3276 return;
3279 }
3280 let rewritten = match serde_json::to_string_pretty(&value) {
3281 Ok(rewritten) => rewritten,
3282 Err(e) => {
3283 tracing::error!(
3284 "Failed to serialize config.json after stripping legacy `connect` key: {}",
3285 e
3286 );
3287 return;
3288 }
3289 };
3290 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
3291 tracing::error!(
3292 "Failed to write config.json after stripping legacy `connect` key: {}",
3293 e
3294 );
3295 }
3296}
3297
3298fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
3335 let config_path = data_dir.join("config.json");
3336 for gen in 0..BAK_GENERATIONS {
3337 let backup = backup_path_for(&config_path, gen);
3338 let content = match std::fs::read_to_string(&backup) {
3339 Ok(content) => content,
3340 Err(e) => {
3341 if e.kind() != std::io::ErrorKind::NotFound {
3342 tracing::warn!(
3343 "Failed to read {:?} while scanning for legacy connect data ({}); \
3344 leaving it untouched",
3345 backup,
3346 e
3347 );
3348 }
3349 continue;
3350 }
3351 };
3352 let mut value: serde_json::Value = match serde_json::from_str(&content) {
3353 Ok(value) => value,
3354 Err(e) => {
3355 tracing::warn!(
3356 "Skipping unparsable backup {:?} while scanning for legacy connect data \
3357 ({}); left untouched (never deleted)",
3358 backup,
3359 e
3360 );
3361 continue;
3362 }
3363 };
3364 let Some(obj) = value.as_object_mut() else {
3365 continue;
3368 };
3369 if obj.remove("connect").is_none() {
3370 continue;
3373 }
3374 let rewritten = match serde_json::to_string_pretty(&value) {
3375 Ok(rewritten) => rewritten,
3376 Err(e) => {
3377 tracing::error!(
3378 "Failed to serialize {:?} after stripping legacy connect data: {}",
3379 backup,
3380 e
3381 );
3382 continue;
3383 }
3384 };
3385 match write_atomic(&backup, rewritten.as_bytes()) {
3386 Ok(()) => tracing::info!(
3387 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
3388 backup
3389 ),
3390 Err(e) => tracing::error!(
3391 "Failed to write {:?} after stripping legacy connect data: {}",
3392 backup,
3393 e
3394 ),
3395 }
3396 }
3397}
3398
3399fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
3414 let backup = connect_path.with_extension("json.bak");
3415 match std::fs::rename(connect_path, &backup) {
3416 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
3417 Err(e) => {
3418 tracing::warn!(
3419 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
3420 backup,
3421 e
3422 );
3423 if let Err(e) = std::fs::copy(connect_path, &backup) {
3424 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
3425 return;
3426 }
3427 if let Err(e) = std::fs::remove_file(connect_path) {
3428 tracing::error!(
3429 "Quarantined corrupt connect.json to {:?} but failed to remove the \
3430 original {:?}: {}",
3431 backup,
3432 connect_path,
3433 e
3434 );
3435 }
3436 }
3437 }
3438}
3439
3440const QUARANTINE_KEEP: usize = 5;
3443
3444fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
3453 let nanos = std::time::SystemTime::now()
3454 .duration_since(std::time::UNIX_EPOCH)
3455 .map(|d| d.as_nanos())
3456 .unwrap_or(0);
3457 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
3461 let mut dedup = 1u32;
3462 while quarantine.exists() {
3463 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
3464 dedup += 1;
3465 }
3466 let result = match std::fs::copy(config_path, &quarantine) {
3467 Ok(_) => {
3468 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
3469 Some(quarantine)
3470 }
3471 Err(e) => {
3472 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
3473 None
3474 }
3475 };
3476 prune_quarantine_files(config_path, QUARANTINE_KEEP);
3477 result
3478}
3479
3480fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
3483 let Some(dir) = config_path.parent() else {
3484 return;
3485 };
3486 let prefix = "config.json.corrupted.";
3487 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
3488 Ok(entries) => entries
3489 .filter_map(|e| e.ok())
3490 .map(|e| e.path())
3491 .filter(|p| {
3492 p.file_name()
3493 .and_then(|n| n.to_str())
3494 .is_some_and(|n| n.starts_with(prefix))
3495 })
3496 .collect(),
3497 Err(_) => return,
3498 };
3499 if quarantines.len() <= keep {
3500 return;
3501 }
3502 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
3504 let remove = quarantines.len() - keep;
3505 for stale in quarantines.into_iter().take(remove) {
3506 if let Err(e) = std::fs::remove_file(&stale) {
3507 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
3508 }
3509 }
3510}
3511
3512const BAK_GENERATIONS: usize = 3;
3515
3516fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
3518 if gen == 0 {
3519 config_path.with_extension("json.bak")
3520 } else {
3521 config_path.with_extension(format!("json.bak.{gen}"))
3522 }
3523}
3524
3525fn rotate_backups(config_path: &std::path::Path, generations: usize) {
3531 for gen in (1..generations).rev() {
3532 let from = backup_path_for(config_path, gen - 1);
3533 let to = backup_path_for(config_path, gen);
3534 if from.exists() {
3535 if let Err(e) = std::fs::rename(&from, &to) {
3536 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
3537 }
3538 }
3539 }
3540}
3541
3542fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
3543 let Some(parent) = path.parent() else {
3544 return std::fs::write(path, content);
3545 };
3546
3547 std::fs::create_dir_all(parent)?;
3548
3549 static NEXT_TMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3568 let unique = NEXT_TMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3569 let file_name = path
3570 .file_name()
3571 .and_then(|s| s.to_str())
3572 .unwrap_or("config.json");
3573 let tmp_name = format!(".{}.tmp.{}.{}", file_name, std::process::id(), unique);
3574 let tmp_path = parent.join(tmp_name);
3575
3576 {
3577 let mut file = std::fs::File::create(&tmp_path)?;
3578 file.write_all(content)?;
3579 file.sync_all()?;
3580 }
3581
3582 std::fs::rename(&tmp_path, path)?;
3583 Ok(())
3584}
3585
3586#[cfg(test)]
3587mod tests {
3588 use super::*;
3589 use std::ffi::OsString;
3590 use std::path::PathBuf;
3591 use std::sync::Mutex;
3592 use std::time::{SystemTime, UNIX_EPOCH};
3593
3594 struct EnvVarGuard {
3595 key: &'static str,
3596 previous: Option<OsString>,
3597 }
3598
3599 impl EnvVarGuard {
3600 fn set(key: &'static str, value: &str) -> Self {
3601 let previous = std::env::var_os(key);
3602 std::env::set_var(key, value);
3603 Self { key, previous }
3604 }
3605
3606 fn unset(key: &'static str) -> Self {
3607 let previous = std::env::var_os(key);
3608 std::env::remove_var(key);
3609 Self { key, previous }
3610 }
3611 }
3612
3613 impl Drop for EnvVarGuard {
3614 fn drop(&mut self) {
3615 match &self.previous {
3616 Some(value) => std::env::set_var(self.key, value),
3617 None => std::env::remove_var(self.key),
3618 }
3619 }
3620 }
3621
3622 #[test]
3623 fn run_budget_config_merge_is_tighten_only_per_field() {
3624 let config_default = RunBudgetConfig {
3625 max_total_tokens: Some(100_000),
3626 max_tool_calls: Some(500),
3627 max_subagents: Some(10),
3628 };
3629
3630 assert_eq!(
3632 config_default.merged_with_override(None),
3633 config_default,
3634 "no override falls back to the config default entirely"
3635 );
3636
3637 let tighten_one = RunBudgetConfig {
3640 max_total_tokens: Some(5_000),
3641 max_tool_calls: None,
3642 max_subagents: None,
3643 };
3644 let merged = config_default.merged_with_override(Some(&tighten_one));
3645 assert_eq!(merged.max_total_tokens, Some(5_000));
3646 assert_eq!(merged.max_tool_calls, Some(500));
3647 assert_eq!(merged.max_subagents, Some(10));
3648
3649 let loosen_attempt = RunBudgetConfig {
3652 max_total_tokens: Some(999_999_999),
3653 max_tool_calls: Some(10_000),
3654 max_subagents: Some(1_000),
3655 };
3656 assert_eq!(
3657 config_default.merged_with_override(Some(&loosen_attempt)),
3658 config_default,
3659 "looser per-request values must be clamped to the config ceiling"
3660 );
3661
3662 let empty_override = RunBudgetConfig::default();
3666 assert_eq!(
3667 config_default.merged_with_override(Some(&empty_override)),
3668 config_default,
3669 "an all-absent override body keeps every configured ceiling"
3670 );
3671
3672 let unlimited_default = RunBudgetConfig::default();
3676 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
3677 assert_eq!(merged.max_total_tokens, Some(5_000));
3678 assert_eq!(merged.max_tool_calls, None);
3679 assert_eq!(merged.max_subagents, None);
3680 }
3681
3682 #[test]
3683 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
3684 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
3685 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
3686 assert_eq!(RunBudgetConfig::default().max_subagents, None);
3687
3688 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
3689 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
3690 assert_eq!(cfg.max_total_tokens, Some(250_000));
3691 assert_eq!(
3692 cfg.max_tool_calls, None,
3693 "absent field defaults to unlimited"
3694 );
3695 assert_eq!(cfg.max_subagents, Some(3));
3696
3697 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
3701 assert_eq!(empty, "{}");
3702 }
3703
3704 #[test]
3705 fn subagents_config_without_remote_placements_deserializes_empty() {
3706 let json = r#"{ "max_concurrent": 4 }"#;
3709 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3710 assert_eq!(cfg.max_concurrent, Some(4));
3711 assert!(cfg.remote_placements.is_empty());
3712 let back = serde_json::to_string(&cfg).unwrap();
3714 assert!(
3715 !back.contains("remote_placements"),
3716 "empty vec is skipped: {back}"
3717 );
3718 }
3719
3720 #[test]
3721 fn remote_actor_placement_round_trips() {
3722 let json = r#"{
3723 "remote_placements": [
3724 {
3725 "role": "explorer",
3726 "endpoint": "wss://gpu-host:8443",
3727 "token_env": "WORKER_TOKEN",
3728 "ca_cert_file": "/etc/bamboo/worker.pem"
3729 },
3730 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
3731 ]
3732 }"#;
3733 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3734 assert_eq!(cfg.remote_placements.len(), 2);
3735 let p0 = &cfg.remote_placements[0];
3736 assert_eq!(p0.role, "explorer");
3737 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
3738 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
3739 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
3740 let p1 = &cfg.remote_placements[1];
3742 assert_eq!(p1.role, "writer");
3743 assert!(p1.token_env.is_none());
3744 assert!(p1.ca_cert_file.is_none());
3745
3746 let back = serde_json::to_string(&cfg).unwrap();
3747 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3748 assert_eq!(cfg, reparsed, "round-trip is stable");
3749 assert!(!back.contains("\"token_env\":null"));
3750 assert!(!back.contains("\"ca_cert_file\":null"));
3751 }
3752
3753 #[test]
3754 fn subagents_config_without_schedulable_placements_deserializes_empty() {
3755 let json = r#"{ "max_concurrent": 4 }"#;
3758 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3759 assert!(cfg.schedulable_placements.is_empty());
3760 let back = serde_json::to_string(&cfg).unwrap();
3762 assert!(
3763 !back.contains("schedulable_placements"),
3764 "empty vec is skipped: {back}"
3765 );
3766 }
3767
3768 #[test]
3769 fn schedulable_placement_round_trips() {
3770 let json = r#"{
3771 "schedulable_placements": [
3772 {
3773 "role": "explorer",
3774 "pool": "gpu-pool",
3775 "registry_url": "https://control-plane:9562",
3776 "token_env": "WORKER_TOKEN",
3777 "ca_cert_file": "/etc/bamboo/worker.pem"
3778 },
3779 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
3780 ]
3781 }"#;
3782 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3783 assert_eq!(cfg.schedulable_placements.len(), 2);
3784 let p0 = &cfg.schedulable_placements[0];
3785 assert_eq!(p0.role, "explorer");
3786 assert_eq!(p0.pool, "gpu-pool");
3787 assert_eq!(p0.registry_url, "https://control-plane:9562");
3788 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
3789 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
3790 let p1 = &cfg.schedulable_placements[1];
3792 assert_eq!(p1.role, "writer");
3793 assert_eq!(p1.pool, "cpu-pool");
3794 assert!(p1.token_env.is_none());
3795 assert!(p1.ca_cert_file.is_none());
3796
3797 let back = serde_json::to_string(&cfg).unwrap();
3798 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3799 assert_eq!(cfg, reparsed, "round-trip is stable");
3800 assert!(!back.contains("\"token_env\":null"));
3801 assert!(!back.contains("\"ca_cert_file\":null"));
3802 }
3803
3804 #[test]
3805 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
3806 let json = r#"{ "max_concurrent": 4 }"#;
3810 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3811 assert!(cfg.mcp_role_allowlist.is_empty());
3812 let back = serde_json::to_string(&cfg).unwrap();
3814 assert!(
3815 !back.contains("mcp_role_allowlist"),
3816 "empty vec is skipped: {back}"
3817 );
3818 }
3819
3820 #[test]
3821 fn mcp_role_allowlist_entry_round_trips() {
3822 let json = r#"{
3823 "mcp_role_allowlist": [
3824 { "role": "researcher", "tools": ["fetch_url"] },
3825 { "role": "sandboxed", "tools": [] }
3826 ]
3827 }"#;
3828 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3829 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
3830 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
3831 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
3832 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
3835 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
3836
3837 let back = serde_json::to_string(&cfg).unwrap();
3838 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3839 assert_eq!(cfg, reparsed, "round-trip is stable");
3840 }
3841
3842 #[test]
3843 fn server_config_without_tls_field_deserializes_back_compat() {
3844 let server: ServerConfig = serde_json::from_value(serde_json::json!({
3847 "port": 9562,
3848 "bind": "127.0.0.1"
3849 }))
3850 .expect("legacy server config without tls should deserialize");
3851
3852 assert_eq!(server.tls, None);
3853 assert_eq!(server.port, 9562);
3854 assert_eq!(server.bind, "127.0.0.1");
3855 }
3856
3857 #[test]
3858 fn server_config_omits_tls_when_none() {
3859 let server = ServerConfig::default();
3862 let value = serde_json::to_value(&server).expect("server config should serialize");
3863 let obj = value
3864 .as_object()
3865 .expect("server config serializes to object");
3866 assert!(
3867 !obj.contains_key("tls"),
3868 "tls must be omitted when None, got: {value}"
3869 );
3870 }
3871
3872 #[test]
3873 fn server_config_with_tls_roundtrips() {
3874 let server: ServerConfig = serde_json::from_value(serde_json::json!({
3875 "port": 9562,
3876 "bind": "0.0.0.0",
3877 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
3878 }))
3879 .expect("server config with tls should deserialize");
3880
3881 let tls = server.tls.clone().expect("tls should be Some");
3882 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
3883 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
3884
3885 let value = serde_json::to_value(&server).expect("serialize");
3887 assert!(value.as_object().unwrap().contains_key("tls"));
3888 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
3889 assert_eq!(back.tls, server.tls);
3890 }
3891
3892 #[test]
3893 fn access_control_without_devices_field_deserializes_back_compat() {
3894 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
3897 "password_enabled": true,
3898 "password_hash": "deadbeef",
3899 "password_salt": "01020304",
3900 }))
3901 .expect("legacy access_control without devices should deserialize");
3902
3903 assert!(access.devices.is_empty());
3904 assert!(access.password_enabled);
3905 }
3906
3907 #[test]
3908 fn access_control_omits_devices_when_empty() {
3909 let access = AccessControlConfig {
3912 password_enabled: true,
3913 password_hash: Some("deadbeef".to_string()),
3914 password_salt: Some("01020304".to_string()),
3915 updated_at: None,
3916 devices: Vec::new(),
3917 };
3918 let value = serde_json::to_value(&access).expect("serialize");
3919 let obj = value.as_object().expect("object");
3920 assert!(
3921 !obj.contains_key("devices"),
3922 "devices must be omitted when empty, got: {value}"
3923 );
3924 }
3925
3926 #[test]
3927 fn access_control_with_devices_roundtrips() {
3928 let device = DeviceCredential {
3929 device_id: "bamboo_0123456789ab".to_string(),
3930 label: "iPhone 15".to_string(),
3931 token_hash: "abcd".to_string(),
3932 token_salt: "ef01".to_string(),
3933 created_at: "2026-06-23T00:00:00Z".to_string(),
3934 last_used_at: None,
3935 revoked: false,
3936 };
3937 let access = AccessControlConfig {
3938 password_enabled: true,
3939 password_hash: Some("deadbeef".to_string()),
3940 password_salt: Some("01020304".to_string()),
3941 updated_at: None,
3942 devices: vec![device.clone()],
3943 };
3944 let value = serde_json::to_value(&access).expect("serialize");
3945 assert!(value.as_object().unwrap().contains_key("devices"));
3946 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
3947 assert_eq!(back.devices, vec![device]);
3948 }
3949
3950 #[test]
3951 fn reasoning_effort_for_key_resolves_instance_id() {
3952 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
3956 "provider_type": "copilot",
3957 "reasoning_effort": "high",
3958 }))
3959 .expect("instance config should deserialize");
3960
3961 let mut config = Config::create_default();
3962 config
3963 .provider_instances
3964 .insert("copilot-work".to_string(), instance);
3965
3966 assert_eq!(
3967 config.reasoning_effort_for_key("copilot-work"),
3968 Some(ReasoningEffort::High),
3969 );
3970 }
3971
3972 #[test]
3973 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
3974 let mut config = Config::create_default();
3976 config.providers.bodhi = Some(
3977 serde_json::from_value(serde_json::json!({
3978 "reasoning_effort": "xhigh",
3979 }))
3980 .expect("bodhi config should deserialize"),
3981 );
3982
3983 assert_eq!(
3984 config.reasoning_effort_for_key("bodhi"),
3985 Some(ReasoningEffort::Xhigh),
3986 );
3987 }
3988
3989 #[test]
3990 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
3991 let mut config = Config::create_default();
3992 config.providers.openai = Some(
3993 serde_json::from_value(serde_json::json!({
3994 "api_key": "sk-test",
3995 "reasoning_effort": "low",
3996 }))
3997 .expect("openai config should deserialize"),
3998 );
3999
4000 assert_eq!(
4001 config.reasoning_effort_for_key("openai"),
4002 Some(ReasoningEffort::Low),
4003 );
4004 }
4005
4006 #[test]
4007 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
4008 let config = Config::create_default();
4009 assert_eq!(config.reasoning_effort_for_key("nope"), None);
4010 assert_eq!(config.reasoning_effort_for_key(" "), None);
4011 }
4012
4013 struct TempHome {
4014 path: PathBuf,
4015 }
4016
4017 impl TempHome {
4018 fn new() -> Self {
4019 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
4031 std::sync::atomic::AtomicU64::new(0);
4032 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4033 let nanos = SystemTime::now()
4034 .duration_since(UNIX_EPOCH)
4035 .expect("clock should be after unix epoch")
4036 .as_nanos();
4037 let path = std::env::temp_dir().join(format!(
4038 "chat-core-config-test-{}-{}-{}",
4039 std::process::id(),
4040 nanos,
4041 unique
4042 ));
4043 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
4044 Self { path }
4045 }
4046
4047 fn set_config_json(&self, content: &str) {
4048 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
4051 std::fs::write(self.path.join("config.json"), content)
4052 .expect("failed to write config.json");
4053 }
4054 }
4055
4056 impl Drop for TempHome {
4057 fn drop(&mut self) {
4058 let _ = std::fs::remove_dir_all(&self.path);
4059 }
4060 }
4061
4062 fn env_lock() -> &'static Mutex<()> {
4066 crate::test_support::env_cache_lock()
4067 }
4068
4069 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
4071 env_lock().lock().unwrap_or_else(|poisoned| {
4072 poisoned.into_inner()
4074 })
4075 }
4076
4077 #[test]
4078 fn parse_bool_env_true_values() {
4079 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
4080 assert!(parse_bool_env(value), "value {value:?} should be true");
4081 }
4082 }
4083
4084 #[test]
4085 fn parse_bool_env_false_values() {
4086 for value in ["0", "false", "no", "off", "", " "] {
4087 assert!(!parse_bool_env(value), "value {value:?} should be false");
4088 }
4089 }
4090
4091 #[test]
4092 fn config_new_ignores_http_proxy_env_vars() {
4093 let _lock = env_lock_acquire();
4094 let temp_home = TempHome::new();
4095 temp_home.set_config_json(
4096 r#"{
4097 "http_proxy": "",
4098 "https_proxy": ""
4099}"#,
4100 );
4101
4102 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
4103 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
4104
4105 let config = Config::from_data_dir(Some(temp_home.path.clone()));
4106
4107 assert!(
4108 config.http_proxy.is_empty(),
4109 "config should ignore HTTP_PROXY env var"
4110 );
4111 assert!(
4112 config.https_proxy.is_empty(),
4113 "config should ignore HTTPS_PROXY env var"
4114 );
4115 }
4116
4117 #[test]
4118 fn config_new_loads_config_when_proxy_fields_omitted() {
4119 let _lock = env_lock_acquire();
4120 let temp_home = TempHome::new();
4121 temp_home.set_config_json(
4122 r#"{
4123 "provider": "openai",
4124 "providers": {
4125 "openai": {
4126 "api_key": "sk-test",
4127 "model": "gpt-4o"
4128 }
4129 }
4130}"#,
4131 );
4132
4133 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
4134 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
4135
4136 let config = Config::from_data_dir(Some(temp_home.path.clone()));
4137
4138 assert_eq!(
4139 config
4140 .providers
4141 .openai
4142 .as_ref()
4143 .and_then(|c| c.model.as_deref()),
4144 Some("gpt-4o"),
4145 "config should load provider model from config file even when proxy fields are omitted"
4146 );
4147 assert!(config.http_proxy.is_empty());
4148 assert!(config.https_proxy.is_empty());
4149 }
4150
4151 #[test]
4152 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
4153 let _lock = crate::test_support::env_cache_lock_acquire();
4154 let config = Config {
4155 env_vars: vec![
4156 EnvVarEntry {
4157 name: "SECRET_TOKEN".to_string(),
4158 value: "top-secret".to_string(),
4159 secret: true,
4160 value_encrypted: None,
4161 description: Some("Service token".to_string()),
4162 },
4163 EnvVarEntry {
4164 name: "API_BASE".to_string(),
4165 value: "https://internal.example".to_string(),
4166 secret: false,
4167 value_encrypted: None,
4168 description: Some("Internal API base".to_string()),
4169 },
4170 ],
4171 ..Default::default()
4172 };
4173
4174 config.publish_env_vars();
4175
4176 let injected = Config::current_env_vars();
4177 assert_eq!(
4178 injected.get("SECRET_TOKEN").map(String::as_str),
4179 Some("top-secret")
4180 );
4181 assert_eq!(
4182 injected.get("API_BASE").map(String::as_str),
4183 Some("https://internal.example")
4184 );
4185
4186 let prompt_safe = Config::current_prompt_safe_env_vars();
4187 assert_eq!(prompt_safe.len(), 2);
4188 assert!(prompt_safe.iter().any(|entry| {
4189 entry.name == "SECRET_TOKEN"
4190 && entry.secret
4191 && entry.description.as_deref() == Some("Service token")
4192 }));
4193 assert!(prompt_safe.iter().any(|entry| {
4194 entry.name == "API_BASE"
4195 && !entry.secret
4196 && entry.description.as_deref() == Some("Internal API base")
4197 }));
4198 assert!(!prompt_safe
4199 .iter()
4200 .any(|entry| entry.name.contains("top-secret")));
4201 assert!(!prompt_safe.iter().any(|entry| {
4202 entry
4203 .description
4204 .as_deref()
4205 .is_some_and(|value| value.contains("https://internal.example"))
4206 }));
4207 }
4208
4209 #[test]
4210 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
4211 let _lock = crate::test_support::env_cache_lock_acquire();
4212
4213 Config {
4215 env_vars: vec![EnvVarEntry {
4216 name: "BAMBOO_CACHE_OWNER_40".to_string(),
4217 value: "live".to_string(),
4218 secret: false,
4219 value_encrypted: None,
4220 description: None,
4221 }],
4222 ..Default::default()
4223 }
4224 .publish_env_vars();
4225 assert_eq!(
4226 Config::current_env_vars()
4227 .get("BAMBOO_CACHE_OWNER_40")
4228 .map(String::as_str),
4229 Some("live")
4230 );
4231
4232 let temp = TempHome::new();
4234 temp.set_config_json(
4235 &serde_json::json!({
4236 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
4237 })
4238 .to_string(),
4239 );
4240
4241 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4244 assert_eq!(
4245 loaded
4246 .env_vars
4247 .iter()
4248 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
4249 .map(|e| e.value.as_str()),
4250 Some("stale-disk"),
4251 "the returned Config holds the disk value"
4252 );
4253 assert_eq!(
4254 Config::current_env_vars()
4255 .get("BAMBOO_CACHE_OWNER_40")
4256 .map(String::as_str),
4257 Some("live"),
4258 "but the global cache is UNTOUCHED — no clobber (#40)"
4259 );
4260
4261 let _ = Config::from_data_dir(Some(temp.path.clone()));
4263 assert_eq!(
4264 Config::current_env_vars()
4265 .get("BAMBOO_CACHE_OWNER_40")
4266 .map(String::as_str),
4267 Some("stale-disk"),
4268 "the publishing loader clobbers the cache (contrast)"
4269 );
4270 }
4271
4272 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
4273 std::fs::read_dir(dir)
4274 .unwrap()
4275 .filter_map(|e| e.ok())
4276 .any(|e| {
4277 e.file_name()
4278 .to_string_lossy()
4279 .contains("config.json.corrupted.")
4280 })
4281 }
4282
4283 #[test]
4284 fn corrupt_config_recovered_from_backup_and_quarantined() {
4285 let temp = TempHome::new();
4286 std::fs::write(
4288 temp.path.join("config.json.bak"),
4289 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
4290 )
4291 .unwrap();
4292 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
4294
4295 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4296 assert_eq!(
4297 config.http_proxy, "http://from-backup",
4298 "recovered from config.json.bak instead of losing all config"
4299 );
4300 assert!(
4301 dir_has_quarantine_file(&temp.path),
4302 "corrupt config.json was quarantined (preserved), not discarded"
4303 );
4304 }
4305
4306 #[test]
4307 fn corrupt_config_without_backup_quarantines_then_defaults() {
4308 let temp = TempHome::new();
4309 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
4310
4311 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4312 assert!(
4313 config.http_proxy.is_empty(),
4314 "no backup -> falls back to defaults"
4315 );
4316 assert!(
4317 dir_has_quarantine_file(&temp.path),
4318 "corrupt config.json is quarantined even when there's no backup"
4319 );
4320 }
4321
4322 #[test]
4323 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
4324 let temp = TempHome::new();
4325 temp.set_config_json(
4330 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
4331 );
4332
4333 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4334 assert_eq!(
4335 config.http_proxy, "http://salvaged",
4336 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
4337 );
4338 assert!(
4339 config.env_vars.is_empty(),
4340 "the corrupt field reset to its default instead of failing the whole load"
4341 );
4342 assert!(
4343 dir_has_quarantine_file(&temp.path),
4344 "the corrupt config.json was still quarantined for inspection"
4345 );
4346 }
4347
4348 #[test]
4349 fn salvage_preferred_over_backup_for_most_recent_intent() {
4350 let temp = TempHome::new();
4351 std::fs::write(
4353 temp.path.join("config.json.bak"),
4354 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
4355 )
4356 .unwrap();
4357 temp.set_config_json(
4359 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
4360 );
4361
4362 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4363 assert_eq!(
4364 config.http_proxy, "http://new-salvaged",
4365 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
4366 );
4367 }
4368
4369 #[test]
4370 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
4371 let temp = TempHome::new();
4372 std::fs::write(
4374 temp.path.join("config.json.bak"),
4375 serde_json::json!({
4376 "http_proxy": "http://old-from-backup",
4377 "https_proxy": "https://kept-from-backup",
4378 })
4379 .to_string(),
4380 )
4381 .unwrap();
4382 temp.set_config_json(
4385 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
4386 );
4387
4388 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4389 assert_eq!(
4391 config.http_proxy, "http://newer-edit",
4392 "the corrupt file's newer valid edit is applied"
4393 );
4394 assert_eq!(
4396 config.https_proxy, "https://kept-from-backup",
4397 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
4398 );
4399 }
4400
4401 #[test]
4402 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
4403 let temp = TempHome::new();
4404 std::fs::write(
4407 temp.path.join("config.json.bak"),
4408 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
4409 )
4410 .unwrap();
4411 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
4412
4413 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4414 assert_eq!(
4415 config.http_proxy, "http://from-backup",
4416 "garbage (non-object) config skips salvage and recovers from .bak"
4417 );
4418 }
4419
4420 #[test]
4421 fn quarantine_files_are_capped_to_newest_n() {
4422 let temp = TempHome::new();
4423 let config_path = temp.path.join("config.json");
4424 std::fs::write(&config_path, "{}").unwrap();
4425
4426 for _ in 0..(QUARANTINE_KEEP + 3) {
4429 quarantine_corrupt_config(&config_path);
4430 std::thread::sleep(std::time::Duration::from_millis(3));
4431 }
4432
4433 let count = std::fs::read_dir(&temp.path)
4434 .unwrap()
4435 .filter_map(|e| e.ok())
4436 .filter(|e| {
4437 e.file_name()
4438 .to_string_lossy()
4439 .starts_with("config.json.corrupted.")
4440 })
4441 .count();
4442 assert_eq!(
4443 count, QUARANTINE_KEEP,
4444 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
4445 );
4446 }
4447
4448 #[test]
4449 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
4450 let temp = TempHome::new();
4451 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
4453 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
4454 std::fs::write(
4455 temp.path.join("config.json.bak.1"),
4456 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
4457 )
4458 .unwrap();
4459
4460 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4461 assert_eq!(
4462 config.http_proxy, "http://from-gen-1",
4463 "recovered from .bak.1 when both config.json and .bak are corrupt"
4464 );
4465 }
4466
4467 #[test]
4468 fn save_rotates_backup_generations() {
4469 let temp = TempHome::new();
4470 let path = temp.path.join("config.json");
4471 std::fs::write(
4473 &path,
4474 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
4475 )
4476 .unwrap();
4477
4478 let mut cfg = Config::create_default();
4479 cfg.http_proxy = "http://proxy-v2".to_string();
4481 cfg.save_to_dir(temp.path.clone()).unwrap();
4482 cfg.http_proxy = "http://proxy-v3".to_string();
4484 cfg.save_to_dir(temp.path.clone()).unwrap();
4485
4486 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
4487 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
4488 assert!(
4489 bak.contains("proxy-v2"),
4490 ".bak holds the previous generation (v2)"
4491 );
4492 assert!(
4493 bak1.contains("proxy-v1"),
4494 ".bak.1 holds the older rotated generation (v1)"
4495 );
4496 }
4497
4498 #[test]
4499 fn save_backs_up_existing_config() {
4500 let temp = TempHome::new();
4501 std::fs::write(
4503 temp.path.join("config.json"),
4504 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
4505 )
4506 .unwrap();
4507
4508 let mut config = Config::create_default();
4509 config.http_proxy = "http://new".to_string();
4510 config
4511 .save_to_dir(temp.path.clone())
4512 .expect("save succeeds");
4513
4514 let backup =
4515 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
4516 assert!(
4517 backup.contains("http://old"),
4518 "config.json.bak holds the PREVIOUS config (last-known-good)"
4519 );
4520 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
4521 assert!(
4522 current.contains("http://new"),
4523 "config.json holds the new config"
4524 );
4525 }
4526
4527 #[test]
4528 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
4529 let temp = TempHome::new();
4530 std::fs::write(
4532 temp.path.join("config.json.bak"),
4533 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
4534 )
4535 .unwrap();
4536 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
4539
4540 let mut config = Config::create_default();
4541 config.http_proxy = "http://new".to_string();
4542 config
4543 .save_to_dir(temp.path.clone())
4544 .expect("save succeeds");
4545
4546 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
4548 assert!(
4549 backup.contains("http://good-bak"),
4550 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
4551 );
4552 }
4553
4554 #[test]
4557 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
4558 let temp = TempHome::new();
4559 std::fs::write(
4560 temp.path.join("config.json.bak"),
4561 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
4562 )
4563 .unwrap();
4564 let corrupt_bytes = "{ not valid json ";
4565 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
4566
4567 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4568 let status = config
4569 .recovery_status()
4570 .expect("a corrupt load must set a pending recovery status");
4571 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
4572 assert_eq!(
4573 status.source,
4574 ConfigRecoverySource::Backup { generation: 0 },
4575 "recovered from generation-0 (.bak)"
4576 );
4577 let quarantine_path = status
4578 .quarantine_path
4579 .as_ref()
4580 .expect("quarantine copy should have succeeded");
4581 assert_eq!(
4582 std::fs::read_to_string(quarantine_path).unwrap(),
4583 corrupt_bytes,
4584 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
4585 );
4586 assert_eq!(
4587 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
4588 corrupt_bytes,
4589 "the original config.json itself is untouched by the load (only copied, not moved)"
4590 );
4591 }
4592
4593 #[test]
4594 fn recovery_status_set_from_salvage_lists_recovered_fields() {
4595 let temp = TempHome::new();
4596 temp.set_config_json(
4597 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
4598 );
4599
4600 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4601 let status = config.recovery_status().expect("pending recovery");
4602 assert!(!status.confirmed);
4603 match &status.source {
4604 ConfigRecoverySource::Salvaged { fields } => {
4605 assert!(
4606 fields.iter().any(|f| f == "http_proxy"),
4607 "salvaged fields should list the recovered key: {fields:?}"
4608 );
4609 }
4610 other => panic!("expected Salvaged source, got {other:?}"),
4611 }
4612 }
4613
4614 #[test]
4615 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
4616 let temp = TempHome::new();
4617 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
4619
4620 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4621 let status = config.recovery_status().expect("pending recovery");
4622 assert!(!status.confirmed);
4623 assert_eq!(status.source, ConfigRecoverySource::Defaults);
4624 }
4625
4626 #[test]
4627 fn clean_load_never_sets_recovery_status() {
4628 let temp = TempHome::new();
4629 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
4630
4631 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4632 assert!(
4633 config.recovery_status().is_none(),
4634 "a config.json that parses cleanly must never carry a pending recovery status"
4635 );
4636 }
4637
4638 #[test]
4639 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
4640 let temp = TempHome::new();
4641 let corrupt_bytes = "}}} broken";
4642 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
4643
4644 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4645 assert!(config.recovery_status().is_some());
4646
4647 let err = config
4648 .save_to_dir(temp.path.clone())
4649 .expect_err("save must refuse while recovery is unconfirmed");
4650 assert!(
4651 err.to_string().contains("recovered from corruption")
4652 || err.to_string().contains("confirm"),
4653 "error should explain the refused overwrite: {err}"
4654 );
4655
4656 assert_eq!(
4659 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
4660 corrupt_bytes,
4661 "a refused save must leave the corrupt original untouched"
4662 );
4663 }
4664
4665 #[test]
4666 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
4667 let temp = TempHome::new();
4668 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
4670 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
4671
4672 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4673 let status = config.recovery_status().expect("pending recovery");
4674 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
4675 assert_eq!(
4676 std::fs::read_to_string(quarantine_path).unwrap(),
4677 truncated,
4678 "truncated original preserved byte for byte in quarantine"
4679 );
4680
4681 let err = config.save_to_dir(temp.path.clone());
4682 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
4683 assert_eq!(
4684 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
4685 truncated,
4686 "the half-written original stays exactly as it was after a refused save"
4687 );
4688 }
4689
4690 #[test]
4691 fn confirm_recovery_allows_the_next_save() {
4692 let temp = TempHome::new();
4693 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
4694
4695 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4696 assert!(config.recovery_status().is_some());
4697
4698 config.confirm_recovery();
4699 assert!(
4700 config.recovery_status().is_some_and(|s| s.confirmed),
4701 "confirm_recovery flips the flag but keeps the status around"
4702 );
4703
4704 config
4705 .save_to_dir(temp.path.clone())
4706 .expect("save must succeed once the recovery is confirmed");
4707 }
4708
4709 #[test]
4710 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
4711 let temp = TempHome::new();
4712 std::fs::write(
4713 temp.path.join("config.json"),
4714 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
4715 )
4716 .unwrap();
4717
4718 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4719 assert!(config.recovery_status().is_some());
4720 let quarantine_path = config
4721 .recovery_status()
4722 .unwrap()
4723 .quarantine_path
4724 .clone()
4725 .unwrap();
4726
4727 config
4728 .confirm_recovery_and_save_to_dir(temp.path.clone())
4729 .expect("confirm+save should succeed");
4730
4731 assert!(
4732 config.recovery_status().is_none(),
4733 "the pending flag is cleared once the recovery is confirmed and persisted"
4734 );
4735 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
4736 assert!(
4737 on_disk.contains("http://recovered"),
4738 "config.json now holds the recovered (salvaged) state"
4739 );
4740 assert!(
4743 quarantine_path.exists(),
4744 "the quarantined original survives confirmation — it's never deleted"
4745 );
4746 }
4747
4748 #[test]
4749 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
4750 let temp = TempHome::new();
4751 let mut config = Config::create_default();
4752 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
4753 assert!(
4754 err.is_err(),
4755 "confirming a recovery that was never pending must error, not silently succeed"
4756 );
4757 }
4758
4759 fn connect_platform_with_encrypted(
4762 platform_type: &str,
4763 token_encrypted: &str,
4764 ) -> ConnectPlatformConfig {
4765 ConnectPlatformConfig {
4766 id: None,
4767 platform_type: platform_type.to_string(),
4768 token: None,
4769 token_encrypted: Some(token_encrypted.to_string()),
4770 app_id: None,
4771 app_secret: None,
4772 app_secret_encrypted: None,
4773 domain: None,
4774 allow_from: vec!["user-1".to_string()],
4775 admin_from: Vec::new(),
4776 }
4777 }
4778
4779 fn connect_json_path(temp: &TempHome) -> PathBuf {
4780 temp.path.join("connect.json")
4781 }
4782
4783 #[test]
4784 fn save_splits_connect_into_sibling_connect_json() {
4785 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4786 let temp = TempHome::new();
4787
4788 let mut config = Config::create_default();
4789 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
4790 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
4791
4792 config
4793 .save_to_dir(temp.path.clone())
4794 .expect("save succeeds");
4795
4796 let config_json: serde_json::Value =
4797 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
4798 .unwrap();
4799 assert!(
4800 config_json.get("connect").is_none(),
4801 "config.json must not carry the `connect` key after a save"
4802 );
4803
4804 let connect_json: serde_json::Value =
4805 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4806 .unwrap();
4807 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
4808 assert!(
4809 connect_json["platforms"][0]["token_encrypted"]
4810 .as_str()
4811 .is_some_and(|v| !v.is_empty()),
4812 "the token is persisted in its encrypted form in connect.json"
4813 );
4814 assert!(
4815 connect_json["platforms"][0].get("token").is_none(),
4816 "the plaintext token is never persisted (skip_serializing)"
4817 );
4818 }
4819
4820 #[test]
4823 fn save_assigns_a_missing_connect_platform_id() {
4824 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4825 let temp = TempHome::new();
4826
4827 let mut config = Config::create_default();
4828 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
4829 assert!(
4830 config.connect.platforms[0].id.is_none(),
4831 "precondition: the entry starts without an id"
4832 );
4833
4834 config
4835 .save_to_dir(temp.path.clone())
4836 .expect("save succeeds");
4837
4838 let connect_json: serde_json::Value =
4839 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4840 .unwrap();
4841 let persisted_id = connect_json["platforms"][0]["id"]
4842 .as_str()
4843 .expect("save_to_dir must backfill a missing id onto the persisted entry");
4844 assert!(!persisted_id.is_empty());
4845
4846 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4847 assert_eq!(
4848 reloaded.connect.platforms[0].id.as_deref(),
4849 Some(persisted_id),
4850 "the assigned id round-trips through a reload"
4851 );
4852 }
4853
4854 #[test]
4855 fn save_never_reassigns_an_existing_connect_platform_id() {
4856 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4857 let temp = TempHome::new();
4858
4859 let mut config = Config::create_default();
4860 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
4861 platform.id = Some("stable-id-123".to_string());
4862 config.connect.platforms = vec![platform];
4863
4864 config
4865 .save_to_dir(temp.path.clone())
4866 .expect("first save succeeds");
4867 config
4869 .save_to_dir(temp.path.clone())
4870 .expect("second save succeeds");
4871
4872 let connect_json: serde_json::Value =
4873 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4874 .unwrap();
4875 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
4876 }
4877
4878 #[test]
4879 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
4880 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4881 let temp = TempHome::new();
4882
4883 let mut config = Config::create_default();
4884 config.connect.platforms = vec![
4885 connect_platform_with_encrypted("telegram", "cipher-a"),
4886 connect_platform_with_encrypted("telegram", "cipher-b"),
4887 ];
4888
4889 config
4890 .save_to_dir(temp.path.clone())
4891 .expect("save succeeds");
4892
4893 let connect_json: serde_json::Value =
4894 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4895 .unwrap();
4896 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
4897 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
4898 assert_ne!(
4899 id_a, id_b,
4900 "two entries sharing platform_type must still get distinct ids"
4901 );
4902 }
4903
4904 #[test]
4905 fn load_never_assigns_or_persists_an_id_by_itself() {
4906 let temp = TempHome::new();
4907 std::fs::write(
4908 connect_json_path(&temp),
4909 serde_json::json!({
4910 "platforms": [
4911 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
4912 ]
4913 })
4914 .to_string(),
4915 )
4916 .unwrap();
4917 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
4918
4919 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4920
4921 assert!(
4922 config.connect.platforms[0].id.is_none(),
4923 "load alone must not backfill an id in memory"
4924 );
4925 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
4926 assert_eq!(
4927 connect_json_before, connect_json_after,
4928 "load must never rewrite connect.json on disk just to backfill an id (#493)"
4929 );
4930 }
4931
4932 #[test]
4933 fn load_merges_connect_json_into_config() {
4934 let temp = TempHome::new();
4935 std::fs::write(
4936 connect_json_path(&temp),
4937 serde_json::json!({
4938 "platforms": [
4939 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
4940 ]
4941 })
4942 .to_string(),
4943 )
4944 .unwrap();
4945
4946 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4947 assert_eq!(config.connect.platforms.len(), 1);
4948 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
4949 assert_eq!(
4950 config.connect.platforms[0].token_encrypted.as_deref(),
4951 Some("cipher-abc")
4952 );
4953 }
4954
4955 #[test]
4956 fn load_without_connect_json_yields_empty_inert_connect_config() {
4957 let temp = TempHome::new();
4958 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
4959
4960 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4961 assert!(
4962 config.connect.platforms.is_empty(),
4963 "no connect.json and no legacy key -> empty/inert connect config"
4964 );
4965 assert!(
4966 !connect_json_path(&temp).exists(),
4967 "load must not create connect.json when there is nothing to migrate"
4968 );
4969 }
4970
4971 #[test]
4972 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
4973 let temp = TempHome::new();
4974 temp.set_config_json(
4976 &serde_json::json!({
4977 "http_proxy": "http://keep-me",
4978 "connect": {
4979 "platforms": [
4980 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
4981 ]
4982 }
4983 })
4984 .to_string(),
4985 );
4986
4987 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4988
4989 assert_eq!(config.connect.platforms.len(), 1);
4991 assert_eq!(
4992 config.connect.platforms[0].token_encrypted.as_deref(),
4993 Some("legacy-cipher")
4994 );
4995 assert_eq!(config.http_proxy, "http://keep-me");
4997
4998 assert!(
5000 connect_json_path(&temp).exists(),
5001 "migration proactively creates connect.json"
5002 );
5003 let connect_json: serde_json::Value =
5004 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
5005 .unwrap();
5006 assert_eq!(
5007 connect_json["platforms"][0]["token_encrypted"], "legacy-cipher",
5008 "the encrypted value is preserved (encrypted form intact) by the migration"
5009 );
5010
5011 let config_json: serde_json::Value =
5013 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
5014 .unwrap();
5015 assert!(
5016 config_json.get("connect").is_none(),
5017 "config.json is rewritten without the legacy `connect` key"
5018 );
5019 }
5020
5021 #[test]
5029 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
5030 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
5031 let temp = TempHome::new();
5032
5033 let original_api_key_encrypted =
5034 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
5035 temp.set_config_json(
5036 &serde_json::json!({
5037 "providers": {
5038 "openai": {
5039 "api_key_encrypted": original_api_key_encrypted,
5040 }
5041 },
5042 "connect": {
5043 "platforms": [
5044 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
5045 ]
5046 }
5047 })
5048 .to_string(),
5049 );
5050
5051 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5052 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
5053
5054 assert!(
5057 !temp.path.join("config.json.bak").exists(),
5058 "a read-only load migrating a legacy `connect` key must not rotate \
5059 config.json backups"
5060 );
5061
5062 let config_json: serde_json::Value =
5067 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
5068 .unwrap();
5069 assert_eq!(
5070 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
5071 "an unrelated secret's ciphertext must not be touched by the connect \
5072 migration's narrow write"
5073 );
5074 assert!(
5075 config_json.get("connect").is_none(),
5076 "config.json is still rewritten without the legacy `connect` key"
5077 );
5078 }
5079
5080 #[test]
5081 fn both_files_present_connect_json_wins() {
5082 let temp = TempHome::new();
5083 temp.set_config_json(
5084 &serde_json::json!({
5085 "connect": {
5086 "platforms": [
5087 { "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
5088 ]
5089 }
5090 })
5091 .to_string(),
5092 );
5093 std::fs::write(
5094 connect_json_path(&temp),
5095 serde_json::json!({
5096 "platforms": [
5097 { "type": "telegram", "token_encrypted": "authoritative-cipher" }
5098 ]
5099 })
5100 .to_string(),
5101 )
5102 .unwrap();
5103
5104 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5105 assert_eq!(
5106 config.connect.platforms[0].token_encrypted.as_deref(),
5107 Some("authoritative-cipher"),
5108 "connect.json wins over a stale legacy config.json key"
5109 );
5110 }
5111
5112 #[test]
5117 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
5118 let temp = TempHome::new();
5119 temp.set_config_json(
5120 &serde_json::json!({
5121 "http_proxy": "http://keep-me",
5122 "connect": {
5123 "platforms": [
5124 { "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
5125 ]
5126 }
5127 })
5128 .to_string(),
5129 );
5130 std::fs::write(
5131 connect_json_path(&temp),
5132 serde_json::json!({
5133 "platforms": [
5134 { "type": "telegram", "token_encrypted": "authoritative-cipher" }
5135 ]
5136 })
5137 .to_string(),
5138 )
5139 .unwrap();
5140
5141 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5142 assert_eq!(
5143 config.connect.platforms[0].token_encrypted.as_deref(),
5144 Some("authoritative-cipher")
5145 );
5146 assert_eq!(config.http_proxy, "http://keep-me");
5148
5149 let config_json: serde_json::Value =
5150 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
5151 .unwrap();
5152 assert!(
5153 config_json.get("connect").is_none(),
5154 "the stale legacy `connect` key must be stripped from config.json \
5155 immediately on load, not left for the next natural save"
5156 );
5157 }
5158
5159 #[test]
5167 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
5168 let temp = TempHome::new();
5169 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
5170 std::fs::write(
5171 temp.path.join("config.json.bak"),
5172 serde_json::json!({
5173 "http_proxy": "http://old",
5174 "connect": {
5175 "platforms": [
5176 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
5177 ]
5178 }
5179 })
5180 .to_string(),
5181 )
5182 .unwrap();
5183
5184 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5185 assert!(config.connect.platforms.is_empty());
5188
5189 let bak: serde_json::Value = serde_json::from_str(
5190 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
5191 )
5192 .unwrap();
5193 assert!(
5194 bak.get("connect").is_none(),
5195 "the legacy `connect` key must be stripped from the tainted .bak generation"
5196 );
5197 assert_eq!(
5198 bak["http_proxy"], "http://old",
5199 "every other key in the .bak generation survives the scrub byte-for-byte in content"
5200 );
5201 }
5202
5203 #[test]
5208 fn scrub_reaches_all_rotated_generations() {
5209 let temp = TempHome::new();
5210 temp.set_config_json(&serde_json::json!({}).to_string());
5211 for (gen_suffix, cipher) in [
5212 ("config.json.bak", "cipher-gen0"),
5213 ("config.json.bak.1", "cipher-gen1"),
5214 ("config.json.bak.2", "cipher-gen2"),
5215 ] {
5216 std::fs::write(
5217 temp.path.join(gen_suffix),
5218 serde_json::json!({
5219 "connect": {
5220 "platforms": [
5221 { "type": "telegram", "token_encrypted": cipher }
5222 ]
5223 }
5224 })
5225 .to_string(),
5226 )
5227 .unwrap();
5228 }
5229
5230 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5231
5232 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
5233 let value: serde_json::Value =
5234 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
5235 .unwrap();
5236 assert!(
5237 value.get("connect").is_none(),
5238 "{gen_suffix} must have its legacy `connect` key stripped"
5239 );
5240 }
5241 }
5242
5243 #[test]
5251 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
5252 let temp = TempHome::new();
5253 temp.set_config_json(&serde_json::json!({}).to_string());
5254 let bak_path = temp.path.join("config.json.bak");
5255 std::fs::write(
5256 &bak_path,
5257 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
5258 )
5259 .unwrap();
5260
5261 let before_bytes = std::fs::read(&bak_path).unwrap();
5262 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
5263
5264 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5268
5269 let after_bytes = std::fs::read(&bak_path).unwrap();
5270 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
5271 assert_eq!(
5272 before_bytes, after_bytes,
5273 "a .bak generation without a legacy `connect` key must not be rewritten at all"
5274 );
5275 assert_eq!(
5276 before_mtime, after_mtime,
5277 "no write means no mtime change either"
5278 );
5279 }
5280
5281 #[test]
5286 fn scrub_skips_unparsable_backup_without_deleting_it() {
5287 let temp = TempHome::new();
5288 temp.set_config_json(&serde_json::json!({}).to_string());
5289 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
5290
5291 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5292
5293 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
5294 assert_eq!(
5295 content, "{ not valid json",
5296 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
5297 );
5298 }
5299
5300 #[test]
5304 fn scrub_tolerates_missing_generations() {
5305 let temp = TempHome::new();
5306 temp.set_config_json(&serde_json::json!({}).to_string());
5307 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5309 assert!(config.connect.platforms.is_empty());
5310 assert!(!temp.path.join("config.json.bak").exists());
5311 }
5312
5313 #[test]
5316 fn scrub_does_not_break_backup_rotation() {
5317 let temp = TempHome::new();
5318 std::fs::write(
5319 temp.path.join("config.json"),
5320 serde_json::json!({
5321 "http_proxy": "http://proxy-v1",
5322 "connect": {
5323 "platforms": [
5324 { "type": "telegram", "token_encrypted": "legacy-cipher" }
5325 ]
5326 }
5327 })
5328 .to_string(),
5329 )
5330 .unwrap();
5331 std::fs::write(
5332 temp.path.join("config.json.bak"),
5333 serde_json::json!({
5334 "http_proxy": "http://proxy-v0",
5335 "connect": {
5336 "platforms": [
5337 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
5338 ]
5339 }
5340 })
5341 .to_string(),
5342 )
5343 .unwrap();
5344
5345 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5347 let bak: serde_json::Value = serde_json::from_str(
5348 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
5349 )
5350 .unwrap();
5351 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
5352
5353 config.http_proxy = "http://proxy-v2".to_string();
5356 config.save_to_dir(temp.path.clone()).unwrap();
5357
5358 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
5359 assert!(
5360 new_bak.contains("proxy-v1"),
5361 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
5362 );
5363 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
5364 assert!(
5365 new_bak1.contains("proxy-v0"),
5366 ".bak.1 holds the scrubbed older generation after rotation"
5367 );
5368 assert!(
5369 !new_bak1.contains("legacy-bak-cipher"),
5370 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
5371 stripped secret"
5372 );
5373 }
5374
5375 #[test]
5376 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
5377 let temp = TempHome::new();
5378 temp.set_config_json("{}");
5379 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
5380
5381 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5382 assert!(
5383 config.connect.platforms.is_empty(),
5384 "corrupt connect.json fails SAFE to an empty/inert connect config"
5385 );
5386
5387 let backup = connect_json_path(&temp).with_extension("json.bak");
5388 assert!(
5389 backup.exists(),
5390 "the corrupt connect.json is quarantined to connect.json.bak"
5391 );
5392 assert!(
5393 std::fs::read_to_string(backup)
5394 .unwrap()
5395 .contains("not valid json"),
5396 "the quarantined copy holds the bad content"
5397 );
5398 assert!(
5403 !connect_json_path(&temp).exists(),
5404 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
5405 connect.json should remain after quarantine"
5406 );
5407 }
5408
5409 #[test]
5410 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
5411 let temp = TempHome::new();
5412 temp.set_config_json(
5415 &serde_json::json!({
5416 "connect": {
5417 "platforms": [
5418 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
5419 ]
5420 }
5421 })
5422 .to_string(),
5423 );
5424 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
5425
5426 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5427 assert!(
5428 config.connect.platforms.is_empty(),
5429 "corrupt connect.json must not fall back to the legacy config.json copy"
5430 );
5431 }
5432
5433 #[test]
5434 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
5435 let temp = TempHome::new();
5436 let config = Config::create_default();
5437 assert!(config.connect.platforms.is_empty());
5438
5439 config
5440 .save_to_dir(temp.path.clone())
5441 .expect("save succeeds");
5442
5443 assert!(
5444 !connect_json_path(&temp).exists(),
5445 "an empty connect config with no pre-existing file must not create one"
5446 );
5447 }
5448
5449 #[test]
5450 fn connect_json_backed_up_before_overwrite() {
5451 let temp = TempHome::new();
5452 std::fs::write(
5453 connect_json_path(&temp),
5454 serde_json::json!({
5455 "platforms": [
5456 { "type": "telegram", "token_encrypted": "old-cipher" }
5457 ]
5458 })
5459 .to_string(),
5460 )
5461 .unwrap();
5462
5463 let mut config = Config::create_default();
5464 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
5465 config
5466 .save_to_dir(temp.path.clone())
5467 .expect("save succeeds");
5468
5469 let backup = connect_json_path(&temp).with_extension("json.bak");
5470 assert!(
5471 std::fs::read_to_string(backup)
5472 .unwrap()
5473 .contains("old-cipher"),
5474 "the previous connect.json is preserved as connect.json.bak before the overwrite"
5475 );
5476 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
5477 assert!(current.contains("new-cipher"));
5478 }
5479
5480 #[test]
5483 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
5484 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
5485 let temp = TempHome::new();
5486
5487 let mut config = Config::create_default();
5488 config.connect.platforms = vec![ConnectPlatformConfig {
5489 id: None,
5490 platform_type: "feishu".to_string(),
5491 token: None,
5492 token_encrypted: None,
5493 app_id: Some("cli_real_app_id".to_string()),
5494 app_secret: Some("plain-app-secret".to_string()),
5495 app_secret_encrypted: None,
5496 domain: Some("lark".to_string()),
5497 allow_from: vec!["ou_1".to_string()],
5498 admin_from: Vec::new(),
5499 }];
5500
5501 config
5502 .save_to_dir(temp.path.clone())
5503 .expect("save succeeds");
5504
5505 let connect_json: serde_json::Value =
5506 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
5507 .unwrap();
5508 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
5509 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
5510 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
5511 assert!(
5512 connect_json["platforms"][0]["app_secret_encrypted"]
5513 .as_str()
5514 .is_some_and(|v| !v.is_empty()),
5515 "app_secret is persisted in its encrypted form in connect.json"
5516 );
5517 assert!(
5518 connect_json["platforms"][0].get("app_secret").is_none(),
5519 "the plaintext app_secret is never persisted (skip_serializing)"
5520 );
5521 }
5522
5523 #[test]
5524 fn load_hydrates_feishu_app_secret_from_encrypted() {
5525 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
5526 let temp = TempHome::new();
5527
5528 let mut config = Config::create_default();
5529 config.connect.platforms = vec![ConnectPlatformConfig {
5530 id: None,
5531 platform_type: "feishu".to_string(),
5532 token: None,
5533 token_encrypted: None,
5534 app_id: Some("cli_real_app_id".to_string()),
5535 app_secret: Some("plain-app-secret".to_string()),
5536 app_secret_encrypted: None,
5537 domain: Some("lark".to_string()),
5538 allow_from: vec!["ou_1".to_string()],
5539 admin_from: Vec::new(),
5540 }];
5541 config
5542 .save_to_dir(temp.path.clone())
5543 .expect("save succeeds");
5544
5545 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5546 assert_eq!(reloaded.connect.platforms.len(), 1);
5547 assert_eq!(
5548 reloaded.connect.platforms[0].app_secret.as_deref(),
5549 Some("plain-app-secret"),
5550 "reload hydrates app_secret from app_secret_encrypted"
5551 );
5552 assert_eq!(
5553 reloaded.connect.platforms[0].app_id.as_deref(),
5554 Some("cli_real_app_id")
5555 );
5556 assert_eq!(
5557 reloaded.connect.platforms[0].domain.as_deref(),
5558 Some("lark")
5559 );
5560 }
5561
5562 #[test]
5563 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
5564 let temp = TempHome::new();
5565 std::fs::write(
5566 connect_json_path(&temp),
5567 serde_json::json!({
5568 "platforms": [
5569 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
5570 ]
5571 })
5572 .to_string(),
5573 )
5574 .unwrap();
5575
5576 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5577
5578 assert_eq!(config.connect.platforms.len(), 1);
5579 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
5580 assert_eq!(
5581 config.connect.platforms[0].token_encrypted.as_deref(),
5582 Some("legacy-cipher")
5583 );
5584 assert_eq!(
5585 config.connect.platforms[0].app_id, None,
5586 "a legacy entry with no Feishu fields deserializes them as None"
5587 );
5588 assert_eq!(config.connect.platforms[0].app_secret, None);
5589 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
5590 assert_eq!(config.connect.platforms[0].domain, None);
5591 }
5592
5593 #[test]
5594 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
5595 let _lock = env_lock_acquire();
5596 let temp_home = TempHome::new();
5597 temp_home.set_config_json(
5598 r#"{
5599 "provider": "openai",
5600 "providers": {
5601 "openai": {
5602 "api_key": "sk-test",
5603 "model": "gpt-4o"
5604 }
5605 }
5606}"#,
5607 );
5608
5609 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
5610 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
5611
5612 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5613
5614 assert_eq!(
5615 config
5616 .providers
5617 .openai
5618 .as_ref()
5619 .and_then(|c| c.model.as_deref()),
5620 Some("gpt-4o")
5621 );
5622 assert!(
5623 config.http_proxy.is_empty(),
5624 "config should keep http_proxy empty when field is omitted"
5625 );
5626 assert!(
5627 config.https_proxy.is_empty(),
5628 "config should keep https_proxy empty when field is omitted"
5629 );
5630 }
5631
5632 #[test]
5633 fn get_memory_background_model_prefers_memory_specific_override() {
5634 let mut config = Config::default();
5635 config.features.provider_model_ref = false;
5636 config.provider = "openai".to_string();
5637 config.providers.openai = Some(OpenAIConfig {
5638 api_key: "test".to_string(),
5639 api_key_encrypted: None,
5640 base_url: None,
5641 model: Some("gpt-main".to_string()),
5642 fast_model: Some("gpt-fast".to_string()),
5643 vision_model: None,
5644 reasoning_effort: None,
5645 responses_only_models: vec![],
5646 request_overrides: None,
5647 extra: BTreeMap::new(),
5648 api_key_from_env: false,
5649 });
5650 config.memory = Some(MemoryConfig {
5651 background_model: Some("memory-fast".to_string()),
5652 ..MemoryConfig::default()
5653 });
5654
5655 assert_eq!(
5656 config.get_memory_background_model().as_deref(),
5657 Some("memory-fast")
5658 );
5659 }
5660
5661 #[test]
5662 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
5663 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
5668 api_key: api_key.to_string(),
5669 api_key_encrypted: None,
5670 base_url: None,
5671 model: None,
5672 fast_model: None,
5673 vision_model: None,
5674 reasoning_effort: None,
5675 responses_only_models: vec![],
5676 request_overrides: None,
5677 extra: BTreeMap::new(),
5678 api_key_from_env: from_env,
5679 };
5680
5681 let mut current = Config::default();
5683 current.providers.openai = Some(openai("sk-env", true));
5684 let mut merged = Config::default();
5685 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
5687 let got = merged.providers.openai.as_ref().unwrap();
5688 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
5689 assert!(got.api_key_from_env, "env flag restored");
5690
5691 let mut merged = Config::default();
5693 merged.providers.openai = Some(openai("sk-explicit", false));
5694 merged.preserve_env_sourced_provider_keys(¤t);
5695 assert_eq!(
5696 merged.providers.openai.as_ref().unwrap().api_key,
5697 "sk-explicit",
5698 "explicit patch key must win"
5699 );
5700
5701 let mut current_plain = Config::default();
5703 current_plain.providers.openai = Some(openai("sk-plain", false));
5704 let mut merged = Config::default();
5705 merged.providers.openai = Some(openai("", false));
5706 merged.preserve_env_sourced_provider_keys(¤t_plain);
5707 assert!(
5708 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
5709 "non-env key must not be restored by this path"
5710 );
5711 }
5712
5713 #[test]
5714 fn refresh_preserves_ciphertext_when_plaintext_empty() {
5715 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
5719 api_key: api_key.to_string(),
5720 api_key_encrypted: enc.map(str::to_string),
5721 base_url: None,
5722 model: None,
5723 fast_model: None,
5724 vision_model: None,
5725 reasoning_effort: None,
5726 responses_only_models: vec![],
5727 request_overrides: None,
5728 extra: BTreeMap::new(),
5729 api_key_from_env: false,
5730 };
5731
5732 let mut config = Config::default();
5734 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
5735 config
5736 .refresh_provider_api_keys_encrypted()
5737 .expect("refresh");
5738 assert_eq!(
5739 config
5740 .providers
5741 .openai
5742 .as_ref()
5743 .unwrap()
5744 .api_key_encrypted
5745 .as_deref(),
5746 Some("preexisting-ciphertext"),
5747 "existing ciphertext must be preserved when plaintext is empty"
5748 );
5749
5750 let mut config = Config::default();
5752 config.providers.openai = Some(openai("", None));
5753 config
5754 .refresh_provider_api_keys_encrypted()
5755 .expect("refresh");
5756 assert!(
5757 config
5758 .providers
5759 .openai
5760 .as_ref()
5761 .unwrap()
5762 .api_key_encrypted
5763 .is_none(),
5764 "no key + no ciphertext should stay None"
5765 );
5766
5767 let mut config = Config::default();
5769 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
5770 config
5771 .refresh_provider_api_keys_encrypted()
5772 .expect("refresh");
5773 let enc = config
5774 .providers
5775 .openai
5776 .as_ref()
5777 .unwrap()
5778 .api_key_encrypted
5779 .clone()
5780 .expect("ciphertext present");
5781 assert!(
5782 !enc.is_empty() && enc != "stale-ciphertext",
5783 "plaintext re-encrypted"
5784 );
5785 }
5786
5787 #[test]
5788 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
5789 let mut config = Config::default();
5795 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5796 "provider_type": "openai",
5797 "api_key": "sk-instance-live",
5798 }))
5799 .expect("valid instance");
5800 config
5801 .provider_instances
5802 .insert("work".to_string(), instance);
5803
5804 config.refresh_encrypted_secrets().expect("refresh");
5805 assert!(
5806 config.provider_instances["work"]
5807 .api_key_encrypted
5808 .is_some(),
5809 "live config must hold ciphertext after refresh"
5810 );
5811
5812 let value = serde_json::to_value(&config).expect("serialize");
5814 let mut back: Config = serde_json::from_value(value).expect("deserialize");
5815 assert!(
5816 back.provider_instances["work"].api_key.is_empty(),
5817 "plaintext is skip_serializing"
5818 );
5819 back.hydrate_provider_instance_api_keys_from_encrypted();
5820 assert_eq!(
5821 back.provider_instances["work"].api_key, "sk-instance-live",
5822 "key must be recoverable from the round-tripped ciphertext"
5823 );
5824 }
5825
5826 #[test]
5827 fn get_memory_background_model_falls_back_to_provider_fast_model() {
5828 let mut config = Config::default();
5829 config.features.provider_model_ref = false;
5830 config.provider = "openai".to_string();
5831 config.providers.openai = Some(OpenAIConfig {
5832 api_key: "test".to_string(),
5833 api_key_encrypted: None,
5834 base_url: None,
5835 model: Some("gpt-main".to_string()),
5836 fast_model: Some("gpt-fast".to_string()),
5837 vision_model: None,
5838 reasoning_effort: None,
5839 responses_only_models: vec![],
5840 request_overrides: None,
5841 extra: BTreeMap::new(),
5842 api_key_from_env: false,
5843 });
5844
5845 assert_eq!(
5846 config.get_memory_background_model().as_deref(),
5847 Some("gpt-fast")
5848 );
5849 }
5850
5851 #[test]
5852 fn get_memory_background_model_does_not_fall_back_to_main_model() {
5853 let mut config = Config::default();
5854 config.features.provider_model_ref = false;
5855 config.provider = "openai".to_string();
5856 config.providers.openai = Some(OpenAIConfig {
5857 api_key: "test".to_string(),
5858 api_key_encrypted: None,
5859 base_url: None,
5860 model: Some("gpt-main".to_string()),
5861 fast_model: None,
5862 vision_model: None,
5863 reasoning_effort: None,
5864 responses_only_models: vec![],
5865 request_overrides: None,
5866 extra: BTreeMap::new(),
5867 api_key_from_env: false,
5868 });
5869
5870 assert!(config.get_memory_background_model().is_none());
5871 }
5872
5873 #[test]
5874 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
5875 let config = Config {
5876 memory: Some(MemoryConfig {
5877 background_model: Some("dream-fast".to_string()),
5878 auto_dream_enabled: true,
5879 auto_dream_interval_secs: 900,
5880 project_prompt_injection: false,
5881 relevant_recall: false,
5882 relevant_recall_rerank: true,
5883 project_first_dream: false,
5884 ledger_agenda_injection: false,
5885 ledger_gardener_enabled: false,
5886 ledger_gardener_interval_secs: 7_200,
5887 ledger_distillation_enabled: false,
5888 dream_refine_mode: true,
5889 gardener_enabled: true,
5890 gardener_interval_secs: 3_600,
5891 gardener_volume_trigger: 40,
5892 gardener_max_splits_per_run: 4,
5893 gardener_min_sections: 7,
5894 dedup_gardener_enabled: true,
5895 dedup_gardener_min_score: 0.7,
5896 dedup_gardener_max_merges_per_run: 3,
5897 memory_active_capacity: 500,
5898 capacity_max_archivals_per_run: 10,
5899 granularity_freshness_gardener_enabled: false,
5900 }),
5901 ..Config::default()
5902 };
5903
5904 let serialized = serde_json::to_string(&config).expect("config should serialize");
5905 let roundtrip: Config =
5906 serde_json::from_str(&serialized).expect("config should deserialize");
5907 let memory = roundtrip.memory.expect("memory config should exist");
5908 assert!(memory.auto_dream_enabled);
5909 assert!(!memory.project_prompt_injection);
5910 assert!(!memory.relevant_recall);
5911 assert!(memory.relevant_recall_rerank);
5912 assert!(!memory.project_first_dream);
5913 assert!(memory.dream_refine_mode);
5914 assert!(memory.gardener_enabled);
5915 assert_eq!(memory.gardener_interval_secs, 3_600);
5916 assert_eq!(memory.gardener_volume_trigger, 40);
5917 assert_eq!(memory.gardener_max_splits_per_run, 4);
5918 assert_eq!(memory.gardener_min_sections, 7);
5919 assert!(memory.dedup_gardener_enabled);
5920 assert_eq!(memory.dedup_gardener_min_score, 0.7);
5921 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
5922 assert_eq!(memory.memory_active_capacity, 500);
5923 assert_eq!(memory.capacity_max_archivals_per_run, 10);
5924 assert!(!memory.granularity_freshness_gardener_enabled);
5925 }
5926
5927 #[test]
5929 fn memory_active_capacity_defaults_off() {
5930 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
5931 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
5932 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
5933 let memory = parsed.memory.unwrap();
5934 assert_eq!(memory.memory_active_capacity, 0);
5935 assert_eq!(
5936 memory.capacity_max_archivals_per_run, 50,
5937 "omitted field takes the serde default fn"
5938 );
5939 }
5940
5941 #[test]
5945 fn memory_maintenance_integrators_default_on() {
5946 let defaults = MemoryConfig::default();
5947 assert!(defaults.auto_dream_enabled);
5948 assert!(defaults.gardener_enabled);
5949 assert!(defaults.dedup_gardener_enabled);
5950 assert_eq!(defaults.gardener_volume_trigger, 25);
5951
5952 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
5954 let memory = parsed.memory.expect("memory present");
5955 assert!(
5956 memory.auto_dream_enabled,
5957 "auto_dream on when field omitted"
5958 );
5959 assert!(memory.gardener_enabled, "gardener on when field omitted");
5960 assert!(
5961 memory.dedup_gardener_enabled,
5962 "dedup gardener on when field omitted"
5963 );
5964 let opted_out: Config =
5966 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
5967 assert!(!opted_out.memory.unwrap().gardener_enabled);
5968 }
5969
5970 #[test]
5971 fn memory_config_env_overrides_prompt_flags() {
5972 let _lock = env_lock_acquire();
5973 let temp_home = TempHome::new();
5974 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
5975 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
5976 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
5977 let _relevant_recall_rerank =
5978 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
5979 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
5980
5981 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5982 let memory = config
5983 .memory
5984 .expect("memory config should be created by env overrides");
5985 assert!(!memory.project_prompt_injection);
5986 assert!(!memory.relevant_recall);
5987 assert!(memory.relevant_recall_rerank);
5988 assert!(!memory.project_first_dream);
5989 }
5990
5991 #[test]
5992 fn provider_api_keys_injected_from_env_and_never_persisted() {
5993 let _lock = env_lock_acquire();
5994 let temp_home = TempHome::new();
5995 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
5996 let _anthropic = EnvVarGuard::set("BAMBOO_ANTHROPIC_API_KEY", "sk-ant-from-env");
5997 let _openai = EnvVarGuard::set("BAMBOO_OPENAI_API_KEY", "sk-oai-from-env");
5998
5999 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6002 assert_eq!(
6003 config
6004 .providers
6005 .anthropic
6006 .as_ref()
6007 .expect("anthropic created from env")
6008 .api_key,
6009 "sk-ant-from-env"
6010 );
6011 assert_eq!(
6012 config
6013 .providers
6014 .openai
6015 .as_ref()
6016 .expect("openai created from env")
6017 .api_key,
6018 "sk-oai-from-env"
6019 );
6020 assert!(config.providers.gemini.is_none());
6022
6023 config
6029 .save_to_dir(temp_home.path.clone())
6030 .expect("save config");
6031 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
6032 .expect("read persisted config.json");
6033 assert!(
6034 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
6035 "env key must not be persisted as plaintext"
6036 );
6037 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
6038 assert!(
6039 disk_json["providers"]["anthropic"]
6040 .get("api_key_encrypted")
6041 .is_none(),
6042 "env-sourced anthropic key must not be re-encrypted into config.json"
6043 );
6044 assert!(
6045 disk_json["providers"]["openai"]
6046 .get("api_key_encrypted")
6047 .is_none(),
6048 "env-sourced openai key must not be re-encrypted into config.json"
6049 );
6050
6051 drop(_anthropic);
6054 drop(_openai);
6055 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
6056 assert!(reloaded
6057 .providers
6058 .anthropic
6059 .as_ref()
6060 .map(|a| a.api_key.is_empty())
6061 .unwrap_or(true));
6062 }
6063
6064 #[test]
6065 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
6066 let _lock = env_lock_acquire();
6067 let temp_home = TempHome::new();
6068 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
6069 let target = temp_home.path.join("workspace-default");
6070 std::fs::create_dir_all(&target).expect("default work area dir should exist");
6071
6072 let config = Config {
6073 default_work_area: Some(DefaultWorkAreaConfig {
6074 path: Some("~/workspace-default".to_string()),
6075 }),
6076 ..Default::default()
6077 };
6078
6079 assert_eq!(config.get_default_work_area_path(), Some(target));
6080 }
6081
6082 #[test]
6083 fn get_default_work_area_path_returns_none_for_missing_directory() {
6084 let _lock = env_lock_acquire();
6085 let temp_home = TempHome::new();
6086 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
6087
6088 let config = Config {
6089 default_work_area: Some(DefaultWorkAreaConfig {
6090 path: Some("~/missing-default-work-area".to_string()),
6091 }),
6092 ..Default::default()
6093 };
6094
6095 assert!(config.get_default_work_area_path().is_none());
6096 }
6097
6098 #[test]
6099 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
6100 let mut config = Config::default();
6101 config.tools.disabled = vec![
6102 " read_file ".to_string(),
6103 "".to_string(),
6104 "read_file".to_string(),
6105 "bash".to_string(),
6106 "default::getCurrentDir".to_string(),
6107 ];
6108
6109 config.normalize_tool_settings();
6110
6111 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
6112 }
6113
6114 #[test]
6115 fn config_load_reads_disabled_tools_as_canonical_names() {
6116 let _lock = env_lock_acquire();
6117 let temp_home = TempHome::new();
6118 temp_home.set_config_json(
6119 r#"{
6120 "tools": {
6121 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
6122 }
6123}"#,
6124 );
6125
6126 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6127 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
6128 assert!(config.disabled_tool_names().contains("Bash"));
6129 assert!(config.disabled_tool_names().contains("Read"));
6130 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
6131 }
6132
6133 #[test]
6134 fn normalize_skill_settings_trims_dedupes_and_sorts() {
6135 let mut config = Config::default();
6136 config.skills.disabled = vec![
6137 " pdf ".to_string(),
6138 "".to_string(),
6139 "pdf".to_string(),
6140 "skill-creator".to_string(),
6141 ];
6142
6143 config.normalize_skill_settings();
6144
6145 assert_eq!(
6146 config.skills.disabled,
6147 vec!["pdf".to_string(), "skill-creator".to_string()]
6148 );
6149 }
6150
6151 #[test]
6152 fn config_load_reads_disabled_skills_as_normalized_ids() {
6153 let _lock = env_lock_acquire();
6154 let temp_home = TempHome::new();
6155 temp_home.set_config_json(
6156 r#"{
6157 "skills": {
6158 "disabled": [" pdf ", "skill-creator", "pdf", ""]
6159 }
6160}"#,
6161 );
6162
6163 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6164 assert_eq!(
6165 config.skills.disabled,
6166 vec!["pdf".to_string(), "skill-creator".to_string()]
6167 );
6168 assert!(config.disabled_skill_ids().contains("pdf"));
6169 assert!(config.disabled_skill_ids().contains("skill-creator"));
6170 }
6171
6172 #[test]
6173 fn test_server_config_defaults() {
6174 let _lock = env_lock_acquire();
6175 let temp_home = TempHome::new();
6176
6177 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6178 assert_eq!(config.server.port, 9562);
6179 assert_eq!(config.server.bind, "127.0.0.1");
6180 assert_eq!(config.server.workers, 10);
6181 assert!(config.server.static_dir.is_none());
6182 }
6183
6184 #[test]
6185 fn test_server_addr() {
6186 let mut config = Config::default();
6187 config.server.port = 9000;
6188 config.server.bind = "0.0.0.0".to_string();
6189 assert_eq!(config.server_addr(), "0.0.0.0:9000");
6190 }
6191
6192 #[test]
6193 fn test_env_var_overrides() {
6194 let _lock = env_lock_acquire();
6195 let temp_home = TempHome::new();
6196
6197 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
6198 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
6199 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
6200
6201 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6202 assert_eq!(config.server.port, 9999);
6203 assert_eq!(config.server.bind, "192.168.1.1");
6204 assert_eq!(config.provider, "openai");
6205 }
6206
6207 #[test]
6208 fn test_config_save_and_load() {
6209 let _lock = env_lock_acquire();
6210 let temp_home = TempHome::new();
6211
6212 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6213 config.server.port = 9000;
6214 config.server.bind = "0.0.0.0".to_string();
6215 config.provider = "anthropic".to_string();
6216
6217 config
6219 .save_to_dir(temp_home.path.clone())
6220 .expect("Failed to save config");
6221
6222 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6224
6225 assert_eq!(loaded.server.port, 9000);
6227 assert_eq!(loaded.server.bind, "0.0.0.0");
6228 assert_eq!(loaded.provider, "anthropic");
6229 }
6230
6231 #[test]
6232 fn config_decrypts_proxy_auth_from_encrypted_field() {
6233 let _lock = env_lock_acquire();
6234 let temp_home = TempHome::new();
6235
6236 let key_guard = crate::encryption::set_test_encryption_key([
6238 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6239 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6240 0x1c, 0x1d, 0x1e, 0x1f,
6241 ]);
6242
6243 let auth = ProxyAuth {
6244 username: "user".to_string(),
6245 password: "pass".to_string(),
6246 };
6247 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
6248 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
6249
6250 temp_home.set_config_json(&format!(
6251 r#"{{
6252 "http_proxy": "http://proxy.example.com:8080",
6253 "proxy_auth_encrypted": "{encrypted}"
6254}}"#
6255 ));
6256 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6257 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
6258 assert_eq!(loaded_auth.username, "user");
6259 assert_eq!(loaded_auth.password, "pass");
6260 drop(key_guard);
6261 }
6262
6263 #[test]
6264 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
6265 let _lock = env_lock_acquire();
6266 let temp_home = TempHome::new();
6267
6268 let key_guard = crate::encryption::set_test_encryption_key([
6270 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6271 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6272 0x1c, 0x1d, 0x1e, 0x1f,
6273 ]);
6274
6275 let auth = ProxyAuth {
6276 username: "user".to_string(),
6277 password: "pass".to_string(),
6278 };
6279 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
6280 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
6281
6282 temp_home.set_config_json(&format!(
6284 r#"{{
6285 "http_proxy": "http://proxy.example.com:8080",
6286 "http_proxy_auth_encrypted": "{encrypted}",
6287 "https_proxy_auth_encrypted": "{encrypted}"
6288}}"#
6289 ));
6290
6291 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6292 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
6293 assert_eq!(loaded_auth.username, "user");
6294 assert_eq!(loaded_auth.password, "pass");
6295 drop(key_guard);
6296 }
6297
6298 #[test]
6299 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
6300 let _lock = env_lock_acquire();
6301 let temp_home = TempHome::new();
6302
6303 let key_guard = crate::encryption::set_test_encryption_key([
6305 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6306 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6307 0x1c, 0x1d, 0x1e, 0x1f,
6308 ]);
6309
6310 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6311 config.proxy_auth = Some(ProxyAuth {
6312 username: "user".to_string(),
6313 password: "pass".to_string(),
6314 });
6315 config
6316 .save_to_dir(temp_home.path.clone())
6317 .expect("save should encrypt proxy auth");
6318
6319 let content =
6320 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
6321 assert!(
6322 content.contains("proxy_auth_encrypted"),
6323 "config.json should store encrypted proxy auth"
6324 );
6325 assert!(
6326 !content.contains("\"proxy_auth\""),
6327 "config.json should not store plaintext proxy_auth"
6328 );
6329
6330 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6331 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
6332 assert_eq!(loaded_auth.username, "user");
6333 assert_eq!(loaded_auth.password, "pass");
6334 drop(key_guard);
6335 }
6336
6337 #[test]
6338 fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
6339 let _lock = env_lock_acquire();
6340 let temp_home = TempHome::new();
6341
6342 let key_guard = crate::encryption::set_test_encryption_key([
6344 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6345 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6346 0x1c, 0x1d, 0x1e, 0x1f,
6347 ]);
6348
6349 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6350 config.provider = "openai".to_string();
6351 config.providers.openai = Some(OpenAIConfig {
6352 api_key: "sk-test-provider-key".to_string(),
6353 api_key_encrypted: None,
6354 base_url: None,
6355 model: None,
6356 fast_model: None,
6357 vision_model: None,
6358 reasoning_effort: None,
6359 responses_only_models: vec![],
6360 request_overrides: None,
6361 extra: Default::default(),
6362 api_key_from_env: false,
6363 });
6364
6365 config
6366 .save_to_dir(temp_home.path.clone())
6367 .expect("save should encrypt provider api keys");
6368
6369 let content =
6370 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
6371 assert!(
6372 content.contains("\"api_key_encrypted\""),
6373 "config.json should store encrypted provider keys"
6374 );
6375 assert!(
6376 !content.contains("\"api_key\""),
6377 "config.json should not store plaintext provider keys"
6378 );
6379
6380 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6381 let openai = loaded
6382 .providers
6383 .openai
6384 .expect("openai config should be present");
6385 assert_eq!(openai.api_key, "sk-test-provider-key");
6386
6387 drop(key_guard);
6388 }
6389
6390 #[test]
6391 fn config_save_persists_mcp_servers_in_mainstream_format() {
6392 let _lock = env_lock_acquire();
6393 let temp_home = TempHome::new();
6394
6395 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6396
6397 let mut env = std::collections::HashMap::new();
6398 env.insert("TOKEN".to_string(), "supersecret".to_string());
6399
6400 config.mcp.servers = vec![
6401 bamboo_domain::mcp_config::McpServerConfig {
6402 id: "stdio-secret".to_string(),
6403 name: None,
6404 enabled: true,
6405 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
6406 bamboo_domain::mcp_config::StdioConfig {
6407 command: "echo".to_string(),
6408 args: vec![],
6409 cwd: None,
6410 env,
6411 env_encrypted: std::collections::HashMap::new(),
6412 startup_timeout_ms: 5000,
6413 },
6414 ),
6415 request_timeout_ms: 5000,
6416 healthcheck_interval_ms: 1000,
6417 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
6418 allowed_tools: vec![],
6419 denied_tools: vec![],
6420 },
6421 bamboo_domain::mcp_config::McpServerConfig {
6422 id: "sse-secret".to_string(),
6423 name: None,
6424 enabled: true,
6425 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
6426 bamboo_domain::mcp_config::SseConfig {
6427 url: "http://localhost:8080/sse".to_string(),
6428 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
6429 name: "Authorization".to_string(),
6430 value: "Bearer token123".to_string(),
6431 value_encrypted: None,
6432 }],
6433 connect_timeout_ms: 5000,
6434 },
6435 ),
6436 request_timeout_ms: 5000,
6437 healthcheck_interval_ms: 1000,
6438 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
6439 allowed_tools: vec![],
6440 denied_tools: vec![],
6441 },
6442 ];
6443
6444 config
6445 .save_to_dir(temp_home.path.clone())
6446 .expect("save should persist MCP servers");
6447
6448 let content =
6449 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
6450 assert!(
6451 content.contains("\"mcpServers\""),
6452 "config.json should store MCP servers under the mainstream 'mcpServers' key"
6453 );
6454 assert!(
6455 content.contains("supersecret"),
6456 "config.json should persist MCP stdio env in mainstream format"
6457 );
6458 assert!(
6459 content.contains("Bearer token123"),
6460 "config.json should persist MCP SSE headers in mainstream format"
6461 );
6462 assert!(
6463 !content.contains("\"env_encrypted\""),
6464 "config.json should not persist legacy env_encrypted fields"
6465 );
6466 assert!(
6467 !content.contains("\"value_encrypted\""),
6468 "config.json should not persist legacy value_encrypted fields"
6469 );
6470
6471 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6472 let stdio = loaded
6473 .mcp
6474 .servers
6475 .iter()
6476 .find(|s| s.id == "stdio-secret")
6477 .expect("stdio server should exist");
6478 match &stdio.transport {
6479 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
6480 assert_eq!(
6481 stdio.env.get("TOKEN").map(|s| s.as_str()),
6482 Some("supersecret")
6483 );
6484 }
6485 _ => panic!("Expected stdio transport"),
6486 }
6487
6488 let sse = loaded
6489 .mcp
6490 .servers
6491 .iter()
6492 .find(|s| s.id == "sse-secret")
6493 .expect("sse server should exist");
6494 match &sse.transport {
6495 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
6496 assert_eq!(sse.headers[0].value, "Bearer token123");
6497 }
6498 _ => panic!("Expected SSE transport"),
6499 }
6500 }
6501
6502 #[test]
6505 fn env_vars_as_map_includes_only_non_empty_values() {
6506 let config = Config {
6507 env_vars: vec![
6508 EnvVarEntry {
6509 name: "A".to_string(),
6510 value: "val_a".to_string(),
6511 secret: false,
6512 value_encrypted: None,
6513 description: None,
6514 },
6515 EnvVarEntry {
6516 name: "B".to_string(),
6517 value: "".to_string(), secret: true,
6519 value_encrypted: None,
6520 description: None,
6521 },
6522 EnvVarEntry {
6523 name: "C".to_string(),
6524 value: " ".to_string(), secret: false,
6526 value_encrypted: None,
6527 description: None,
6528 },
6529 EnvVarEntry {
6530 name: "D".to_string(),
6531 value: "val_d".to_string(),
6532 secret: true,
6533 value_encrypted: Some("enc".to_string()),
6534 description: Some("desc".to_string()),
6535 },
6536 ],
6537 ..Default::default()
6538 };
6539
6540 let map = config.env_vars_as_map();
6541 assert_eq!(map.len(), 2);
6542 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
6543 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
6544 assert!(!map.contains_key("B"));
6545 assert!(!map.contains_key("C"));
6546 }
6547
6548 #[test]
6549 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
6550 let mut config = Config {
6551 env_vars: vec![
6552 EnvVarEntry {
6553 name: "PLAIN".to_string(),
6554 value: "visible".to_string(),
6555 secret: false,
6556 value_encrypted: None,
6557 description: None,
6558 },
6559 EnvVarEntry {
6560 name: "SECRET".to_string(),
6561 value: "hidden_value".to_string(),
6562 secret: true,
6563 value_encrypted: Some("enc_data".to_string()),
6564 description: None,
6565 },
6566 ],
6567 ..Default::default()
6568 };
6569
6570 config.sanitize_env_vars_for_disk();
6571
6572 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
6575
6576 #[test]
6577 fn sanitize_env_vars_for_disk_preserves_encrypted() {
6578 let mut config = Config {
6579 env_vars: vec![
6580 EnvVarEntry {
6581 name: "OPEN".to_string(),
6582 value: "val".to_string(),
6583 secret: false,
6584 value_encrypted: None,
6585 description: None,
6586 },
6587 EnvVarEntry {
6588 name: "HIDDEN".to_string(),
6589 value: "real_secret".to_string(),
6590 secret: true,
6591 value_encrypted: Some("enc".to_string()),
6592 description: None,
6593 },
6594 ],
6595 ..Default::default()
6596 };
6597
6598 config.sanitize_env_vars_for_disk();
6599
6600 assert_eq!(config.env_vars[0].value, "val");
6602 assert_eq!(config.env_vars[1].value, "");
6604 assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
6605 }
6606
6607 #[test]
6608 fn refresh_env_vars_encrypted_round_trip() {
6609 let mut config = Config {
6610 env_vars: vec![
6611 EnvVarEntry {
6612 name: "TOKEN".to_string(),
6613 value: "my-secret-token".to_string(),
6614 secret: true,
6615 value_encrypted: None,
6616 description: Some("A token".to_string()),
6617 },
6618 EnvVarEntry {
6619 name: "PLAIN_VAR".to_string(),
6620 value: "hello".to_string(),
6621 secret: false,
6622 value_encrypted: None,
6623 description: None,
6624 },
6625 ],
6626 ..Default::default()
6627 };
6628
6629 config
6631 .refresh_env_vars_encrypted()
6632 .expect("encryption should succeed");
6633
6634 assert!(config.env_vars[0].value_encrypted.is_some());
6636 assert!(config.env_vars[1].value_encrypted.is_none());
6638
6639 let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
6641 assert_ne!(encrypted, "my-secret-token"); config.sanitize_env_vars_for_disk();
6645 assert_eq!(config.env_vars[0].value, "");
6646
6647 config.hydrate_env_vars_from_encrypted();
6649 assert_eq!(config.env_vars[0].value, "my-secret-token");
6650 assert_eq!(config.env_vars[1].value, "hello"); }
6652
6653 #[test]
6654 fn publish_and_current_env_vars_round_trip() {
6655 let _lock = crate::test_support::env_cache_lock_acquire();
6665 let config = Config {
6666 env_vars: vec![EnvVarEntry {
6667 name: "TEST_PUBLISH".to_string(),
6668 value: "pub_value".to_string(),
6669 secret: false,
6670 value_encrypted: None,
6671 description: None,
6672 }],
6673 ..Default::default()
6674 };
6675
6676 config.publish_env_vars();
6677 assert_eq!(
6678 Config::current_env_vars()
6679 .get("TEST_PUBLISH")
6680 .map(String::as_str),
6681 Some("pub_value")
6682 );
6683 }
6684
6685 #[test]
6686 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
6687 let mut config = Config::default();
6688 config.subagents.broker = Some(BrokerClientConfig {
6689 endpoint: "ws://127.0.0.1:9600".to_string(),
6690 token: "super-secret-token".to_string(),
6691 token_encrypted: None,
6692 });
6693
6694 config.refresh_broker_token_encrypted().unwrap();
6696 config.sanitize_broker_token_for_disk();
6697 let broker = config.subagents.broker.as_ref().unwrap();
6698 assert!(broker.token.is_empty(), "plaintext cleared for disk");
6699 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
6700 assert_ne!(
6701 broker.token_encrypted.as_deref(),
6702 Some("super-secret-token")
6703 );
6704
6705 config.hydrate_broker_token_from_encrypted();
6707 assert_eq!(
6708 config.subagents.broker.as_ref().unwrap().token,
6709 "super-secret-token"
6710 );
6711 }
6712
6713 #[test]
6714 fn broker_token_empty_refresh_preserves_ciphertext() {
6715 let mut config = Config::default();
6717 config.subagents.broker = Some(BrokerClientConfig {
6718 endpoint: "ws://h:9600".to_string(),
6719 token: String::new(),
6720 token_encrypted: Some("existing-cipher".to_string()),
6721 });
6722 config.refresh_broker_token_encrypted().unwrap();
6723 assert_eq!(
6724 config
6725 .subagents
6726 .broker
6727 .as_ref()
6728 .unwrap()
6729 .token_encrypted
6730 .as_deref(),
6731 Some("existing-cipher"),
6732 );
6733 }
6734
6735 #[test]
6736 fn notifications_config_defaults_when_key_missing() {
6737 let config: Config = serde_json::from_str("{}").expect("empty object parses");
6740 assert_eq!(config.notifications, NotificationsConfig::default());
6741 assert_eq!(config.notifications.desktop.enabled, None);
6742 assert!(!config.notifications.ntfy.enabled);
6743 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
6744 assert_eq!(config.notifications.ntfy.token, None);
6745 assert!(!config.notifications.bark.enabled);
6746 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
6747 assert_eq!(config.notifications.bark.device_key, None);
6748 }
6749
6750 #[test]
6751 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
6752 let mut config = Config::default();
6753 config.notifications.ntfy = NtfyChannelConfig {
6754 enabled: true,
6755 base_url: "https://ntfy.sh".to_string(),
6756 topic: "bamboo-alerts".to_string(),
6757 token: Some("tk_super_secret".to_string()),
6758 token_encrypted: None,
6759 };
6760
6761 config.refresh_notifications_encrypted().unwrap();
6763 assert!(config.notifications.ntfy.token_encrypted.is_some());
6764 assert_ne!(
6765 config.notifications.ntfy.token_encrypted.as_deref(),
6766 Some("tk_super_secret")
6767 );
6768
6769 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
6772 assert!(
6773 !json.contains("tk_super_secret"),
6774 "plaintext token must never be serialized"
6775 );
6776 assert!(json.contains("token_encrypted"));
6777
6778 config.notifications.ntfy.token = None;
6781 config.hydrate_notifications_from_encrypted();
6782 assert_eq!(
6783 config.notifications.ntfy.token.as_deref(),
6784 Some("tk_super_secret")
6785 );
6786 }
6787
6788 #[test]
6789 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
6790 let mut config = Config::default();
6791 config.notifications.bark = BarkChannelConfig {
6792 enabled: true,
6793 base_url: "https://api.day.app".to_string(),
6794 device_key: Some("dk_super_secret".to_string()),
6795 device_key_encrypted: None,
6796 };
6797
6798 config.refresh_notifications_encrypted().unwrap();
6799 assert!(config.notifications.bark.device_key_encrypted.is_some());
6800 assert_ne!(
6801 config.notifications.bark.device_key_encrypted.as_deref(),
6802 Some("dk_super_secret")
6803 );
6804
6805 let json = serde_json::to_string(&config.notifications.bark).unwrap();
6806 assert!(
6807 !json.contains("dk_super_secret"),
6808 "plaintext device key must never be serialized"
6809 );
6810 assert!(json.contains("device_key_encrypted"));
6811
6812 config.notifications.bark.device_key = None;
6813 config.hydrate_notifications_from_encrypted();
6814 assert_eq!(
6815 config.notifications.bark.device_key.as_deref(),
6816 Some("dk_super_secret")
6817 );
6818 }
6819
6820 #[test]
6821 fn notification_secrets_empty_refresh_preserves_ciphertext() {
6822 let mut config = Config::default();
6825 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
6826 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
6827
6828 config.refresh_notifications_encrypted().unwrap();
6829
6830 assert_eq!(
6831 config.notifications.ntfy.token_encrypted.as_deref(),
6832 Some("existing-ntfy-cipher")
6833 );
6834 assert_eq!(
6835 config.notifications.bark.device_key_encrypted.as_deref(),
6836 Some("existing-bark-cipher")
6837 );
6838 }
6839
6840 #[test]
6841 fn hydrate_skips_non_secret_entries() {
6842 let mut config = Config {
6843 env_vars: vec![EnvVarEntry {
6844 name: "PLAIN".to_string(),
6845 value: "original".to_string(),
6846 secret: false,
6847 value_encrypted: Some("should-be-ignored".to_string()),
6848 description: None,
6849 }],
6850 ..Default::default()
6851 };
6852
6853 config.hydrate_env_vars_from_encrypted();
6854 assert_eq!(config.env_vars[0].value, "original");
6856 }
6857
6858 #[test]
6859 fn default_config_has_empty_env_vars() {
6860 assert!(Config::default().env_vars.is_empty());
6865 }
6866
6867 #[test]
6868 fn serde_round_trip_with_env_vars() {
6869 let config = Config {
6870 env_vars: vec![
6871 EnvVarEntry {
6872 name: "KEY1".to_string(),
6873 value: "val1".to_string(),
6874 secret: false,
6875 value_encrypted: None,
6876 description: Some("First key".to_string()),
6877 },
6878 EnvVarEntry {
6879 name: "KEY2".to_string(),
6880 value: "".to_string(), secret: true,
6882 value_encrypted: Some("enc123".to_string()),
6883 description: None,
6884 },
6885 ],
6886 ..Default::default()
6887 };
6888
6889 let json = serde_json::to_string(&config).unwrap();
6890 let restored: Config = serde_json::from_str(&json).unwrap();
6891
6892 assert_eq!(restored.env_vars.len(), 2);
6893 assert_eq!(restored.env_vars[0].name, "KEY1");
6894 assert_eq!(restored.env_vars[0].value, "val1");
6895 assert!(!restored.env_vars[0].secret);
6896 assert_eq!(restored.env_vars[1].name, "KEY2");
6897 assert!(restored.env_vars[1].secret);
6898 assert_eq!(
6899 restored.env_vars[1].value_encrypted.as_deref(),
6900 Some("enc123")
6901 );
6902 }
6903
6904 #[test]
6907 #[allow(clippy::field_reassign_with_default)]
6909 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
6910 let mut config = Config::default();
6911 config.provider = "openai".to_string();
6912 config.providers.openai = Some(OpenAIConfig {
6913 api_key: "test".to_string(),
6914 api_key_encrypted: None,
6915 base_url: None,
6916 model: Some("legacy-gpt-4o".to_string()),
6917 fast_model: None,
6918 vision_model: None,
6919 reasoning_effort: None,
6920 responses_only_models: vec![],
6921 request_overrides: None,
6922 extra: Default::default(),
6923 api_key_from_env: false,
6924 });
6925 config.features.provider_model_ref = true;
6926 config.defaults = Some(DefaultsConfig {
6927 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
6928 fast: None,
6929 task_summary: None,
6930 vision: None,
6931 memory_background: None,
6932 planning: None,
6933 search: None,
6934 code_review: None,
6935 sub_agent: None,
6936 subagent_models: Default::default(),
6937 });
6938
6939 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
6940 }
6941
6942 #[test]
6943 #[allow(clippy::field_reassign_with_default)]
6945 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
6946 let mut config = Config::default();
6947 config.provider = "openai".to_string();
6948 config.providers.openai = Some(OpenAIConfig {
6949 api_key: "test".to_string(),
6950 api_key_encrypted: None,
6951 base_url: None,
6952 model: Some("legacy-gpt-4o".to_string()),
6953 fast_model: None,
6954 vision_model: None,
6955 reasoning_effort: None,
6956 responses_only_models: vec![],
6957 request_overrides: None,
6958 extra: Default::default(),
6959 api_key_from_env: false,
6960 });
6961 config.features.provider_model_ref = false;
6962 config.defaults = Some(DefaultsConfig {
6963 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
6964 fast: None,
6965 task_summary: None,
6966 vision: None,
6967 memory_background: None,
6968 planning: None,
6969 search: None,
6970 code_review: None,
6971 sub_agent: None,
6972 subagent_models: Default::default(),
6973 });
6974
6975 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
6976 }
6977
6978 #[test]
6979 #[allow(clippy::field_reassign_with_default)]
6981 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
6982 let mut config = Config::default();
6983 config.provider = "openai".to_string();
6984 config.providers.openai = Some(OpenAIConfig {
6985 api_key: "test".to_string(),
6986 api_key_encrypted: None,
6987 base_url: None,
6988 model: Some("gpt-4o".to_string()),
6989 fast_model: Some("legacy-gpt-4o-mini".to_string()),
6990 vision_model: None,
6991 reasoning_effort: None,
6992 responses_only_models: vec![],
6993 request_overrides: None,
6994 extra: Default::default(),
6995 api_key_from_env: false,
6996 });
6997 config.features.provider_model_ref = true;
6998 config.defaults = Some(DefaultsConfig {
6999 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7000 fast: Some(bamboo_domain::ProviderModelRef::new(
7001 "anthropic",
7002 "claude-3-5-haiku",
7003 )),
7004 task_summary: None,
7005 vision: None,
7006 memory_background: None,
7007 planning: None,
7008 search: None,
7009 code_review: None,
7010 sub_agent: None,
7011 subagent_models: Default::default(),
7012 });
7013
7014 assert_eq!(
7015 config.get_fast_model(),
7016 Some("claude-3-5-haiku".to_string())
7017 );
7018 }
7019
7020 #[test]
7021 #[allow(clippy::field_reassign_with_default)]
7023 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
7024 let mut config = Config::default();
7025 config.provider = "openai".to_string();
7026 config.providers.openai = Some(OpenAIConfig {
7027 api_key: "test".to_string(),
7028 api_key_encrypted: None,
7029 base_url: None,
7030 model: Some("gpt-4o".to_string()),
7031 fast_model: Some("legacy-gpt-4o-mini".to_string()),
7032 vision_model: None,
7033 reasoning_effort: None,
7034 responses_only_models: vec![],
7035 request_overrides: None,
7036 extra: Default::default(),
7037 api_key_from_env: false,
7038 });
7039 config.features.provider_model_ref = false;
7040 config.defaults = Some(DefaultsConfig {
7041 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7042 fast: Some(bamboo_domain::ProviderModelRef::new(
7043 "anthropic",
7044 "claude-3-5-haiku",
7045 )),
7046 task_summary: None,
7047 vision: None,
7048 memory_background: None,
7049 planning: None,
7050 search: None,
7051 code_review: None,
7052 sub_agent: None,
7053 subagent_models: Default::default(),
7054 });
7055
7056 assert_eq!(
7057 config.get_fast_model(),
7058 Some("legacy-gpt-4o-mini".to_string())
7059 );
7060 }
7061
7062 #[test]
7063 #[allow(clippy::field_reassign_with_default)]
7065 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
7066 let mut config = Config::default();
7067 config.provider = "openai".to_string();
7068 config.features.provider_model_ref = true;
7069 config.defaults = Some(DefaultsConfig {
7070 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
7071 fast: None,
7072 task_summary: None,
7073 vision: None,
7074 memory_background: None,
7075 planning: None,
7076 search: None,
7077 code_review: None,
7078 sub_agent: None,
7079 subagent_models: Default::default(),
7080 });
7081
7082 assert_eq!(
7083 config.get_fast_model(),
7084 Some("claude-3-7-sonnet".to_string())
7085 );
7086 }
7087
7088 #[test]
7089 #[allow(clippy::field_reassign_with_default)]
7091 fn get_memory_background_model_prefers_defaults_memory_background() {
7092 let mut config = Config::default();
7093 config.provider = "openai".to_string();
7094 config.providers.openai = Some(OpenAIConfig {
7095 api_key: "test".to_string(),
7096 api_key_encrypted: None,
7097 base_url: None,
7098 model: Some("gpt-4o".to_string()),
7099 fast_model: Some("gpt-4o-mini".to_string()),
7100 vision_model: None,
7101 reasoning_effort: None,
7102 responses_only_models: vec![],
7103 request_overrides: None,
7104 extra: Default::default(),
7105 api_key_from_env: false,
7106 });
7107 config.features.provider_model_ref = true;
7108 config.defaults = Some(DefaultsConfig {
7109 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7110 fast: Some(bamboo_domain::ProviderModelRef::new(
7111 "openai",
7112 "gpt-4o-mini",
7113 )),
7114 task_summary: None,
7115 vision: None,
7116 memory_background: Some(bamboo_domain::ProviderModelRef::new(
7117 "anthropic",
7118 "claude-3-5-haiku",
7119 )),
7120 planning: None,
7121 search: None,
7122 code_review: None,
7123 sub_agent: None,
7124 subagent_models: Default::default(),
7125 });
7126
7127 assert_eq!(
7128 config.get_memory_background_model(),
7129 Some("claude-3-5-haiku".to_string())
7130 );
7131 }
7132
7133 #[test]
7134 #[allow(clippy::field_reassign_with_default)]
7136 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
7137 let mut config = Config::default();
7138 config.provider = "openai".to_string();
7139 config.features.provider_model_ref = true;
7140 config.defaults = Some(DefaultsConfig {
7141 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7142 fast: Some(bamboo_domain::ProviderModelRef::new(
7143 "anthropic",
7144 "claude-3-5-haiku",
7145 )),
7146 task_summary: None,
7147 vision: None,
7148 memory_background: None,
7149 planning: None,
7150 search: None,
7151 code_review: None,
7152 sub_agent: None,
7153 subagent_models: Default::default(),
7154 });
7155
7156 assert_eq!(
7157 config.get_memory_background_model(),
7158 Some("claude-3-5-haiku".to_string())
7159 );
7160 }
7161
7162 #[test]
7163 #[allow(clippy::field_reassign_with_default)]
7165 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
7166 let mut config = Config::default();
7167 config.provider = "openai".to_string();
7168 config.providers.openai = Some(OpenAIConfig {
7169 api_key: "test".to_string(),
7170 api_key_encrypted: None,
7171 base_url: None,
7172 model: Some("gpt-4o".to_string()),
7173 fast_model: Some("legacy-gpt-4o-mini".to_string()),
7174 vision_model: None,
7175 reasoning_effort: None,
7176 responses_only_models: vec![],
7177 request_overrides: None,
7178 extra: Default::default(),
7179 api_key_from_env: false,
7180 });
7181 config.features.provider_model_ref = false;
7182 config.defaults = Some(DefaultsConfig {
7183 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7184 fast: Some(bamboo_domain::ProviderModelRef::new(
7185 "anthropic",
7186 "claude-3-5-haiku",
7187 )),
7188 task_summary: None,
7189 vision: None,
7190 memory_background: Some(bamboo_domain::ProviderModelRef::new(
7191 "anthropic",
7192 "claude-3-5-haiku",
7193 )),
7194 planning: None,
7195 search: None,
7196 code_review: None,
7197 sub_agent: None,
7198 subagent_models: Default::default(),
7199 });
7200
7201 assert_eq!(
7202 config.get_memory_background_model(),
7203 Some("legacy-gpt-4o-mini".to_string())
7204 );
7205 }
7206
7207 #[test]
7214 fn is_host_trusted_requires_https_scheme() {
7215 let hosts = vec!["github.com/bigduu/".to_string()];
7216 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
7217 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
7218 }
7219
7220 #[test]
7221 fn is_host_trusted_is_case_insensitive_on_both_sides() {
7222 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
7224 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
7225 let hosts = vec!["github.com/bigduu/".to_string()];
7227 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
7228 }
7229
7230 #[test]
7231 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
7232 let hosts = vec!["trusted.example.com".to_string()];
7233 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
7234 assert!(!is_host_trusted(
7238 "https://trusted.example.com.evil.com/x",
7239 &hosts
7240 ));
7241 assert!(!is_host_trusted(
7242 "https://trusted.example.comevil.com/x",
7243 &hosts
7244 ));
7245 }
7246
7247 #[test]
7248 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
7249 let hosts = vec!["github.com/bigduu/".to_string()];
7251 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
7252 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
7253 }
7254
7255 #[test]
7256 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
7257 let hosts = vec!["example.com".to_string()];
7258 assert!(is_host_trusted("https://example.com/", &hosts));
7259 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
7260 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
7263 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
7264 }
7265
7266 #[test]
7267 fn is_host_trusted_uses_the_real_host_not_userinfo() {
7268 let hosts = vec!["github.com/bigduu/".to_string()];
7269 assert!(is_host_trusted(
7271 "https://someuser@github.com/bigduu/x",
7272 &hosts
7273 ));
7274 assert!(!is_host_trusted(
7277 "https://github.com@evil.com/bigduu/",
7278 &hosts
7279 ));
7280 }
7281
7282 #[test]
7283 fn is_host_trusted_ignores_an_explicit_port() {
7284 let hosts = vec!["github.com/bigduu/".to_string()];
7285 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
7286 }
7287
7288 #[test]
7289 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
7290 let hosts = vec!["github.com/bigduu/".to_string()];
7291 assert!(!is_host_trusted("not a url at all", &hosts));
7292 assert!(!is_host_trusted("", &hosts));
7293 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
7295
7296 #[test]
7297 fn is_host_trusted_normalizes_dot_segments_before_matching() {
7298 let hosts = vec!["github.com/bigduu/".to_string()];
7299 assert!(!is_host_trusted(
7302 "https://github.com/bigduu/../evil/x",
7303 &hosts
7304 ));
7305 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
7307 }
7308
7309 #[test]
7310 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
7311 let mut config = Config::default();
7312 config.plugin_trust.trusted_hosts = vec![
7313 " GitHub.com/BigDuu/ ".to_string(),
7314 "".to_string(),
7315 " ".to_string(),
7316 "Example.COM".to_string(),
7317 ];
7318 config.normalize_plugin_trust_settings();
7319 assert_eq!(
7320 config.plugin_trust.trusted_hosts,
7321 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
7322 );
7323 }
7324
7325 #[test]
7331 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
7332 let config = Config::default();
7334 assert_eq!(
7335 config.plugin_trust.enforcement,
7336 PluginTrustEnforcement::Strict
7337 );
7338 assert!(!config.plugin_trust.enforcement_is_off());
7339
7340 let json = serde_json::json!({
7346 "trusted_hosts": ["example.com"],
7347 "trusted_keys": [],
7348 });
7349 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
7350 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
7351 }
7352
7353 #[test]
7354 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
7355 for raw in ["off", "OFF", "Off", " off "] {
7356 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
7357 "enforcement": raw,
7358 }))
7359 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
7360 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
7361 assert!(trust.enforcement_is_off());
7362 }
7363 for raw in ["strict", "STRICT", " Strict "] {
7364 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
7365 "enforcement": raw,
7366 }))
7367 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
7368 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
7369 }
7370
7371 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
7372 "enforcement": "nonsense",
7373 }))
7374 .expect_err("an unrecognized string must be rejected, not silently default");
7375 assert!(err.to_string().contains("nonsense"));
7376 }
7377
7378 #[test]
7379 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
7380 let trust: PluginTrustConfig =
7383 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
7384 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
7385
7386 let trust: PluginTrustConfig =
7387 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
7388 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
7389 }
7390
7391 #[test]
7392 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
7393 let trust = PluginTrustConfig {
7398 enforcement: PluginTrustEnforcement::Off,
7399 ..PluginTrustConfig::default()
7400 };
7401 let json = serde_json::to_value(&trust).unwrap();
7402 assert_eq!(json["enforcement"], "off");
7403
7404 let trust = PluginTrustConfig {
7405 enforcement: PluginTrustEnforcement::Strict,
7406 ..PluginTrustConfig::default()
7407 };
7408 let json = serde_json::to_value(&trust).unwrap();
7409 assert_eq!(json["enforcement"], "strict");
7410 }
7411
7412 #[test]
7413 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
7414 let mut config = Config::default();
7417 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
7418 config.normalize_plugin_trust_settings();
7419 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
7420 }
7421
7422 #[test]
7423 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
7424 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
7429 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
7430 )));
7431 assert_eq!(
7432 config.plugin_trust.enforcement,
7433 PluginTrustEnforcement::Strict
7434 );
7435
7436 let outcome = crate::dot_path::apply_dot_path_set(
7437 &config,
7438 "plugin_trust.enforcement",
7439 crate::dot_path::parse_cli_value("off"),
7440 )
7441 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
7442 assert_eq!(
7443 outcome.config.plugin_trust.enforcement,
7444 PluginTrustEnforcement::Off
7445 );
7446
7447 let outcome = crate::dot_path::apply_dot_path_set(
7449 &outcome.config,
7450 "plugin_trust.enforcement",
7451 crate::dot_path::parse_cli_value("strict"),
7452 )
7453 .expect("setting it back to strict should also round-trip");
7454 assert_eq!(
7455 outcome.config.plugin_trust.enforcement,
7456 PluginTrustEnforcement::Strict
7457 );
7458 }
7459}