1use anyhow::{Context, Result};
60use bamboo_domain::poison::PoisonRecover;
61use serde::{Deserialize, Serialize};
62use serde_json::Value;
63use std::collections::{BTreeMap, BTreeSet, HashMap};
64use std::path::{Path, PathBuf};
65use std::sync::{OnceLock, RwLock};
66
67use crate::keyword_masking::KeywordMaskingConfig;
68use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
69use bamboo_domain::normalize_tool_ref;
70use bamboo_domain::ReasoningEffort;
71
72pub const MIN_STREAM_TIMEOUT_SECS: u64 = 1;
75pub const MAX_STREAM_TIMEOUT_SECS: u64 = 86_400;
79
80fn default_transport_idle_timeout_secs() -> u64 {
81 120
82}
83
84fn default_first_semantic_timeout_secs() -> u64 {
85 600
86}
87
88fn default_semantic_idle_timeout_secs() -> u64 {
89 600
90}
91
92fn deserialize_stream_timeout_secs<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
93where
94 D: serde::Deserializer<'de>,
95{
96 use serde::de::Error as _;
97
98 let value = u64::deserialize(deserializer)?;
99 if (MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
100 Ok(value)
101 } else {
102 Err(D::Error::custom(format!(
103 "stream timeout must be between {MIN_STREAM_TIMEOUT_SECS} and \
104 {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
105 )))
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(default)]
119pub struct StreamTimeoutConfig {
120 #[serde(
122 default = "default_transport_idle_timeout_secs",
123 deserialize_with = "deserialize_stream_timeout_secs"
124 )]
125 pub transport_idle_timeout_secs: u64,
126 #[serde(
128 default = "default_first_semantic_timeout_secs",
129 deserialize_with = "deserialize_stream_timeout_secs"
130 )]
131 pub first_semantic_timeout_secs: u64,
132 #[serde(
134 default = "default_semantic_idle_timeout_secs",
135 deserialize_with = "deserialize_stream_timeout_secs"
136 )]
137 pub semantic_idle_timeout_secs: u64,
138}
139
140impl Default for StreamTimeoutConfig {
141 fn default() -> Self {
142 Self {
143 transport_idle_timeout_secs: default_transport_idle_timeout_secs(),
144 first_semantic_timeout_secs: default_first_semantic_timeout_secs(),
145 semantic_idle_timeout_secs: default_semantic_idle_timeout_secs(),
146 }
147 }
148}
149
150impl StreamTimeoutConfig {
151 pub fn validate(&self) -> std::result::Result<(), String> {
154 for (name, value) in [
155 (
156 "transport_idle_timeout_secs",
157 self.transport_idle_timeout_secs,
158 ),
159 (
160 "first_semantic_timeout_secs",
161 self.first_semantic_timeout_secs,
162 ),
163 (
164 "semantic_idle_timeout_secs",
165 self.semantic_idle_timeout_secs,
166 ),
167 ] {
168 if !(MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
169 return Err(format!(
170 "{name} must be between {MIN_STREAM_TIMEOUT_SECS} and \
171 {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
172 ));
173 }
174 }
175 Ok(())
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct EnvVarEntry {
186 pub name: String,
188 #[serde(default, skip_serializing_if = "String::is_empty")]
191 pub value: String,
192 #[serde(default)]
194 pub secret: bool,
195 #[serde(default, skip_serializing)]
198 pub value_encrypted: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub credential_ref: Option<crate::CredentialRef>,
202 #[serde(default)]
204 pub configured: bool,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub description: Option<String>,
208}
209
210#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
215pub struct DefaultWorkAreaConfig {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub path: Option<String>,
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
223pub struct AccessControlConfig {
224 #[serde(default)]
226 pub password_enabled: bool,
227 #[serde(default)]
232 pub repair_required: bool,
233 #[serde(default, skip_serializing)]
237 pub password_hash: Option<String>,
238 #[serde(default, skip_serializing)]
240 pub password_salt: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub password_credential_ref: Option<crate::CredentialRef>,
244 #[serde(default)]
246 pub password_configured: bool,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub updated_at: Option<String>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub devices: Vec<DeviceCredential>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264pub struct DeviceCredential {
265 pub device_id: String,
267 pub label: String,
269 #[serde(default, skip_serializing)]
271 pub token_hash: String,
272 #[serde(default, skip_serializing)]
274 pub token_salt: String,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub token_credential_ref: Option<crate::CredentialRef>,
278 #[serde(default)]
280 pub token_configured: bool,
281 pub created_at: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub last_used_at: Option<String>,
286 #[serde(default)]
289 pub revoked: bool,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
295pub struct MemoryConfig {
296 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub background_model: Option<String>,
300 #[serde(default = "default_summary_target_ratio")]
304 pub summary_target_ratio: f64,
305 #[serde(default = "default_summary_safe_window_percent")]
309 pub summary_safe_window_percent: u8,
310 #[serde(default = "default_true_auto_dream_enabled")]
315 pub auto_dream_enabled: bool,
316 #[serde(default = "default_auto_dream_interval_secs")]
320 pub auto_dream_interval_secs: u64,
321 #[serde(
323 default = "default_true_memory_project_prompt_injection",
324 alias = "memory_project_prompt_injection"
325 )]
326 pub project_prompt_injection: bool,
327 #[serde(
329 default = "default_true_memory_relevant_recall",
330 alias = "memory_relevant_recall"
331 )]
332 pub relevant_recall: bool,
333 #[serde(default, alias = "memory_relevant_recall_rerank")]
336 pub relevant_recall_rerank: bool,
337 #[serde(
339 default = "default_true_memory_project_first_dream",
340 alias = "memory_project_first_dream"
341 )]
342 pub project_first_dream: bool,
343 #[serde(
347 default = "default_true_memory_ledger_agenda",
348 alias = "memory_ledger_agenda_injection"
349 )]
350 pub ledger_agenda_injection: bool,
351 #[serde(default = "default_true_ledger_gardener_enabled")]
356 pub ledger_gardener_enabled: bool,
357 #[serde(default = "default_ledger_gardener_interval_secs")]
359 pub ledger_gardener_interval_secs: u64,
360 #[serde(default = "default_true_ledger_distillation_enabled")]
363 pub ledger_distillation_enabled: bool,
364 #[serde(default, alias = "memory_dream_refine_mode")]
371 pub dream_refine_mode: bool,
372 #[serde(
378 default = "default_true_gardener_enabled",
379 alias = "memory_gardener_enabled"
380 )]
381 pub gardener_enabled: bool,
382 #[serde(default = "default_gardener_interval_secs")]
385 pub gardener_interval_secs: u64,
386 #[serde(default = "default_gardener_volume_trigger")]
391 pub gardener_volume_trigger: usize,
392 #[serde(default = "default_gardener_max_splits_per_run")]
394 pub gardener_max_splits_per_run: usize,
395 #[serde(default = "default_gardener_min_sections")]
397 pub gardener_min_sections: usize,
398 #[serde(
403 default = "default_true_dedup_gardener_enabled",
404 alias = "memory_dedup_gardener_enabled"
405 )]
406 pub dedup_gardener_enabled: bool,
407 #[serde(default = "default_dedup_gardener_min_score")]
410 pub dedup_gardener_min_score: f64,
411 #[serde(default = "default_dedup_gardener_max_merges_per_run")]
413 pub dedup_gardener_max_merges_per_run: usize,
414 #[serde(default)]
422 pub memory_active_capacity: usize,
423 #[serde(default = "default_capacity_max_archivals_per_run")]
426 pub capacity_max_archivals_per_run: usize,
427 #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
435 pub granularity_freshness_gardener_enabled: bool,
436}
437
438impl Default for MemoryConfig {
439 fn default() -> Self {
440 Self {
441 background_model: None,
442 summary_target_ratio: default_summary_target_ratio(),
443 summary_safe_window_percent: default_summary_safe_window_percent(),
444 auto_dream_enabled: default_true_auto_dream_enabled(),
445 auto_dream_interval_secs: default_auto_dream_interval_secs(),
446 project_prompt_injection: default_true_memory_project_prompt_injection(),
447 relevant_recall: default_true_memory_relevant_recall(),
448 relevant_recall_rerank: false,
449 project_first_dream: default_true_memory_project_first_dream(),
450 ledger_agenda_injection: default_true_memory_ledger_agenda(),
451 ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
452 ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
453 ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
454 dream_refine_mode: false,
455 gardener_enabled: default_true_gardener_enabled(),
456 gardener_interval_secs: default_gardener_interval_secs(),
457 gardener_volume_trigger: default_gardener_volume_trigger(),
458 gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
459 gardener_min_sections: default_gardener_min_sections(),
460 dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
461 dedup_gardener_min_score: default_dedup_gardener_min_score(),
462 dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
463 memory_active_capacity: 0,
464 capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
465 granularity_freshness_gardener_enabled:
466 default_true_granularity_freshness_gardener_enabled(),
467 }
468 }
469}
470
471fn default_summary_target_ratio() -> f64 {
472 0.20
473}
474
475fn default_summary_safe_window_percent() -> u8 {
476 80
477}
478
479fn default_true_granularity_freshness_gardener_enabled() -> bool {
480 true
481}
482
483fn default_capacity_max_archivals_per_run() -> usize {
484 50
485}
486
487fn default_true_auto_dream_enabled() -> bool {
488 true
489}
490
491fn default_true_gardener_enabled() -> bool {
492 true
493}
494
495fn default_true_dedup_gardener_enabled() -> bool {
496 true
497}
498
499fn default_true_memory_ledger_agenda() -> bool {
500 true
501}
502
503fn default_true_ledger_gardener_enabled() -> bool {
504 true
505}
506
507fn default_ledger_gardener_interval_secs() -> u64 {
508 21_600
509}
510
511fn default_true_ledger_distillation_enabled() -> bool {
512 true
513}
514
515fn default_gardener_volume_trigger() -> usize {
519 25
520}
521
522fn default_gardener_interval_secs() -> u64 {
523 86_400
524}
525
526fn default_auto_dream_interval_secs() -> u64 {
527 60 * 30
528}
529
530fn default_gardener_max_splits_per_run() -> usize {
531 8
532}
533
534fn default_gardener_min_sections() -> usize {
535 5
536}
537
538fn default_dedup_gardener_min_score() -> f64 {
539 0.6
540}
541
542fn default_dedup_gardener_max_merges_per_run() -> usize {
543 8
544}
545
546fn default_true_memory_project_prompt_injection() -> bool {
547 true
548}
549
550fn default_true_memory_relevant_recall() -> bool {
551 true
552}
553
554fn default_true_memory_project_first_dream() -> bool {
555 true
556}
557
558#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
575pub struct RunBudgetConfig {
576 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub max_total_tokens: Option<u64>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub max_tool_calls: Option<u32>,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub max_subagents: Option<u32>,
591}
592
593fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
596 match (config_default, request) {
597 (Some(a), Some(b)) => Some(a.min(b)),
598 (Some(a), None) => Some(a),
599 (None, Some(b)) => Some(b),
600 (None, None) => None,
601 }
602}
603
604impl RunBudgetConfig {
605 pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
620 let Some(over) = request_override else {
621 return *self;
622 };
623 Self {
624 max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
625 max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
626 max_subagents: min_limit(self.max_subagents, over.max_subagents),
627 }
628 }
629}
630
631#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(rename_all = "snake_case")]
638pub enum CodexAuthMode {
639 Inherit,
641 ApiKey,
643 Custom,
645 #[default]
647 Bamboo,
648}
649
650#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum CodexWireApi {
654 #[default]
655 Responses,
656}
657
658#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
660#[serde(rename_all = "snake_case")]
661pub enum CodexMode {
662 #[default]
664 Exec,
665 AppServer,
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
671#[serde(rename_all = "kebab-case")]
672pub enum CodexSandbox {
673 ReadOnly,
674 WorkspaceWrite,
675 DangerFullAccess,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "kebab-case")]
682pub enum CodexApprovalPolicy {
683 Never,
684 OnFailure,
685 OnRequest,
686}
687
688#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
699pub struct SubagentsConfig {
700 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub max_concurrent: Option<usize>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub worker_bin: Option<String>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub worker_args: Option<Vec<String>>,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub fabric_dir: Option<String>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub executor: Option<String>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub claude_code_binary: Option<String>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub claude_code_model: Option<String>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub claude_code_permission_mode: Option<String>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub claude_code_inherit_user_config: Option<bool>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub claude_code_forward_env: Option<Vec<String>>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub codex_binary: Option<String>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub codex_model: Option<String>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub codex_mode: Option<CodexMode>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub codex_auth_mode: Option<CodexAuthMode>,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub codex_base_url: Option<String>,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub codex_wire_api: Option<CodexWireApi>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub codex_provider_key_ref: Option<crate::CredentialRef>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub codex_forward_env: Option<Vec<String>>,
777 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub codex_sandbox: Option<CodexSandbox>,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub codex_approval_policy: Option<CodexApprovalPolicy>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub codex_network_access: Option<bool>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub codex_allow_danger_bypass: Option<bool>,
792 #[serde(skip)]
799 pub broker: Option<BrokerClientConfig>,
800 #[serde(default, skip_serializing_if = "Vec::is_empty")]
806 pub remote_placements: Vec<RemoteActorPlacement>,
807 #[serde(default, skip_serializing_if = "Vec::is_empty")]
820 pub schedulable_placements: Vec<SchedulablePlacement>,
821 #[serde(default, skip_serializing_if = "Vec::is_empty")]
840 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
841}
842
843#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
846pub struct McpRoleAllowlistEntry {
847 pub role: String,
855 #[serde(default)]
863 pub tools: Vec<String>,
864}
865
866#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
882pub struct SchedulablePlacement {
883 pub role: String,
886 pub pool: String,
888 #[serde(default, skip_serializing_if = "String::is_empty")]
892 pub registry_url: String,
893 #[serde(default, skip_serializing_if = "Option::is_none")]
897 pub token_env: Option<String>,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub ca_cert_file: Option<String>,
902}
903
904#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
915pub struct RemoteActorPlacement {
916 pub role: String,
919 pub endpoint: String,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
925 pub token_env: Option<String>,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub ca_cert_file: Option<String>,
930}
931
932#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
934pub struct BrokerClientConfig {
935 pub endpoint: String,
937 #[serde(default, skip_serializing)]
943 pub token: String,
944 #[serde(default, skip_serializing)]
946 pub token_encrypted: Option<String>,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub credential_ref: Option<crate::CredentialRef>,
950 #[serde(default)]
953 pub configured: bool,
954}
955
956impl std::fmt::Debug for BrokerClientConfig {
957 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958 formatter
959 .debug_struct("BrokerClientConfig")
960 .field("endpoint", &self.endpoint)
961 .field("credential_ref", &self.credential_ref)
962 .field("configured", &self.configured)
963 .finish_non_exhaustive()
964 }
965}
966
967#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
969pub struct DesktopChannelConfig {
970 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub enabled: Option<bool>,
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
982pub struct NtfyChannelConfig {
983 #[serde(default)]
984 pub enabled: bool,
985 #[serde(default = "default_ntfy_base_url")]
987 pub base_url: String,
988 #[serde(default)]
991 pub topic: String,
992 #[serde(default, skip_serializing)]
998 pub token: Option<String>,
999 #[serde(default, skip_serializing)]
1001 pub token_encrypted: Option<String>,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub credential_ref: Option<crate::CredentialRef>,
1005 #[serde(default)]
1007 pub configured: bool,
1008}
1009
1010impl Default for NtfyChannelConfig {
1011 fn default() -> Self {
1012 Self {
1013 enabled: false,
1014 base_url: default_ntfy_base_url(),
1015 topic: String::new(),
1016 token: None,
1017 token_encrypted: None,
1018 credential_ref: None,
1019 configured: false,
1020 }
1021 }
1022}
1023
1024fn default_ntfy_base_url() -> String {
1025 "https://ntfy.sh".to_string()
1026}
1027
1028#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1030pub struct BarkChannelConfig {
1031 #[serde(default)]
1032 pub enabled: bool,
1033 #[serde(default = "default_bark_base_url")]
1035 pub base_url: String,
1036 #[serde(default, skip_serializing)]
1041 pub device_key: Option<String>,
1042 #[serde(default, skip_serializing)]
1044 pub device_key_encrypted: Option<String>,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub credential_ref: Option<crate::CredentialRef>,
1048 #[serde(default)]
1050 pub configured: bool,
1051}
1052
1053impl Default for BarkChannelConfig {
1054 fn default() -> Self {
1055 Self {
1056 enabled: false,
1057 base_url: default_bark_base_url(),
1058 device_key: None,
1059 device_key_encrypted: None,
1060 credential_ref: None,
1061 configured: false,
1062 }
1063 }
1064}
1065
1066fn default_bark_base_url() -> String {
1067 "https://api.day.app".to_string()
1068}
1069
1070#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1075pub struct NotificationsConfig {
1076 #[serde(default)]
1077 pub desktop: DesktopChannelConfig,
1078 #[serde(default)]
1079 pub ntfy: NtfyChannelConfig,
1080 #[serde(default)]
1081 pub bark: BarkChannelConfig,
1082}
1083
1084#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1088pub struct ConnectPlatformConfig {
1089 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub id: Option<String>,
1104 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub project_id: Option<bamboo_domain::ProjectId>,
1109 #[serde(rename = "type")]
1113 pub platform_type: String,
1114 #[serde(default, skip_serializing)]
1120 pub token: Option<String>,
1121 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub token_encrypted: Option<String>,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub token_credential_ref: Option<crate::CredentialRef>,
1127 #[serde(default)]
1129 pub token_configured: bool,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub app_id: Option<String>,
1134 #[serde(default, skip_serializing)]
1140 pub app_secret: Option<String>,
1141 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub app_secret_encrypted: Option<String>,
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 pub app_secret_credential_ref: Option<crate::CredentialRef>,
1147 #[serde(default)]
1149 pub app_secret_configured: bool,
1150 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub domain: Option<String>,
1157 #[serde(default)]
1162 pub allow_from: Vec<String>,
1163 #[serde(default)]
1167 pub admin_from: Vec<String>,
1168}
1169
1170#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1174pub struct ConnectConfig {
1175 #[serde(default)]
1176 pub platforms: Vec<ConnectPlatformConfig>,
1177}
1178
1179fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1180 config.platforms.is_empty()
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1190pub struct TrustedKey {
1191 pub label: String,
1193 pub algorithm: String,
1195 pub public_key: String,
1197}
1198
1199fn default_trusted_keys() -> Vec<TrustedKey> {
1206 vec![
1207 TrustedKey {
1208 label: "nova (bigduu official)".to_string(),
1209 algorithm: "ed25519".to_string(),
1210 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1211 .to_string(),
1212 },
1213 TrustedKey {
1214 label: "magpie (bigduu official)".to_string(),
1215 algorithm: "ed25519".to_string(),
1216 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1217 .to_string(),
1218 },
1219 ]
1220}
1221
1222fn default_trusted_hosts() -> Vec<String> {
1225 vec!["github.com/bigduu/".to_string()]
1226}
1227
1228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1245pub struct PluginTrustConfig {
1246 #[serde(default = "default_trusted_hosts")]
1257 pub trusted_hosts: Vec<String>,
1258 #[serde(default = "default_trusted_keys")]
1261 pub trusted_keys: Vec<TrustedKey>,
1262 #[serde(default)]
1276 pub enforcement: PluginTrustEnforcement,
1277}
1278
1279impl Default for PluginTrustConfig {
1280 fn default() -> Self {
1281 Self {
1282 trusted_hosts: default_trusted_hosts(),
1283 trusted_keys: default_trusted_keys(),
1284 enforcement: PluginTrustEnforcement::default(),
1285 }
1286 }
1287}
1288
1289impl PluginTrustConfig {
1290 pub fn is_host_trusted(&self, url: &str) -> bool {
1296 is_host_trusted(url, &self.trusted_hosts)
1297 }
1298
1299 pub fn enforcement_is_off(&self) -> bool {
1305 matches!(self.enforcement, PluginTrustEnforcement::Off)
1306 }
1307}
1308
1309#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1322#[serde(rename_all = "snake_case")]
1323pub enum PluginTrustEnforcement {
1324 #[default]
1329 Strict,
1330 Off,
1334}
1335
1336impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1337 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1338 where
1339 D: serde::Deserializer<'de>,
1340 {
1341 #[derive(Deserialize)]
1342 #[serde(untagged)]
1343 enum Repr {
1344 Bool(bool),
1345 Str(String),
1346 }
1347 match Repr::deserialize(deserializer)? {
1348 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1349 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1350 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1351 "strict" => Ok(PluginTrustEnforcement::Strict),
1352 "off" => Ok(PluginTrustEnforcement::Off),
1353 other => Err(serde::de::Error::custom(format!(
1354 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1355 \"off\""
1356 ))),
1357 },
1358 }
1359 }
1360}
1361
1362struct TrustedHostEntry<'a> {
1367 host: &'a str,
1368 path_prefix: &'a str,
1369}
1370
1371fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1375 match entry.find('/') {
1376 Some(index) => TrustedHostEntry {
1377 host: &entry[..index],
1378 path_prefix: &entry[index..],
1379 },
1380 None => TrustedHostEntry {
1381 host: entry,
1382 path_prefix: "",
1383 },
1384 }
1385}
1386
1387fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1399 if prefix.is_empty() || path == prefix {
1400 return true;
1401 }
1402 if prefix.ends_with('/') {
1403 return path.starts_with(prefix);
1404 }
1405 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1406}
1407
1408pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1432 let Ok(parsed) = url::Url::parse(url) else {
1433 return false;
1434 };
1435 if parsed.scheme() != "https" {
1436 return false;
1437 }
1438 let Some(host) = parsed.host_str() else {
1439 return false;
1440 };
1441 let host = host.to_ascii_lowercase();
1442 let path = parsed.path();
1443
1444 trusted_hosts.iter().any(|raw_entry| {
1445 let entry = raw_entry.trim().to_ascii_lowercase();
1446 let parsed_entry = parse_trusted_host_entry(&entry);
1447 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1448 })
1449}
1450
1451#[derive(Debug, Clone, Serialize, Deserialize)]
1456#[doc(hidden)]
1457pub struct ConfigValues {
1458 #[serde(default)]
1460 pub http_proxy: String,
1461 #[serde(default)]
1463 pub https_proxy: String,
1464 #[serde(skip_serializing)]
1468 pub proxy_auth: Option<ProxyAuth>,
1469 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub proxy_auth_encrypted: Option<String>,
1472 #[serde(default, skip_serializing_if = "Option::is_none")]
1474 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1475 #[serde(default)]
1477 pub headless_auth: bool,
1478
1479 #[serde(default = "default_provider")]
1481 pub provider: String,
1482
1483 #[serde(default, skip_serializing_if = "Option::is_none")]
1485 pub defaults: Option<DefaultsConfig>,
1486
1487 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1494 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1495
1496 #[serde(default, skip_serializing_if = "Option::is_none")]
1500 pub default_provider_instance: Option<String>,
1501
1502 #[serde(default)]
1504 pub server: ServerConfig,
1505
1506 #[serde(default)]
1510 pub keyword_masking: KeywordMaskingConfig,
1511
1512 #[serde(default)]
1516 pub anthropic_model_mapping: AnthropicModelMapping,
1517
1518 #[serde(default)]
1522 pub gemini_model_mapping: GeminiModelMapping,
1523
1524 #[serde(default)]
1529 pub hooks: HooksConfig,
1530
1531 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1537 pub lifecycle_hooks: LifecycleHooksConfig,
1538
1539 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1543 pub tools: ToolsConfig,
1544
1545 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1550 pub skills: SkillsConfig,
1551
1552 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1556 pub env_vars: Vec<EnvVarEntry>,
1557
1558 #[serde(default, skip_serializing_if = "Option::is_none")]
1560 pub default_work_area: Option<DefaultWorkAreaConfig>,
1561
1562 #[serde(default, skip_serializing_if = "Option::is_none")]
1564 pub access_control: Option<AccessControlConfig>,
1565
1566 #[serde(default)]
1568 pub features: FeatureFlags,
1569
1570 #[serde(default)]
1575 pub run_budget: RunBudgetConfig,
1576
1577 #[serde(default)]
1579 pub stream_timeout: StreamTimeoutConfig,
1580
1581 #[serde(
1585 default,
1586 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1587 )]
1588 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1589
1590 #[serde(default, rename = "mcpServers", alias = "mcp")]
1596 pub mcp: bamboo_domain::mcp_config::McpConfig,
1597
1598 #[serde(default)]
1602 pub notifications: NotificationsConfig,
1603
1604 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1609 pub connect: ConnectConfig,
1610
1611 #[serde(default)]
1615 pub plugin_trust: PluginTrustConfig,
1616
1617 #[serde(default, flatten)]
1623 pub extra: BTreeMap<String, Value>,
1624}
1625
1626impl Default for ConfigValues {
1627 fn default() -> Self {
1628 Self {
1629 http_proxy: String::new(),
1630 https_proxy: String::new(),
1631 proxy_auth: None,
1632 proxy_auth_encrypted: None,
1633 proxy_auth_credential_ref: None,
1634 headless_auth: false,
1635 run_budget: RunBudgetConfig::default(),
1636 stream_timeout: StreamTimeoutConfig::default(),
1637 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1638 provider: default_provider(),
1639 provider_instances: HashMap::new(),
1640 default_provider_instance: None,
1641 server: ServerConfig::default(),
1642 keyword_masking: KeywordMaskingConfig::default(),
1643 anthropic_model_mapping: AnthropicModelMapping::default(),
1644 gemini_model_mapping: GeminiModelMapping::default(),
1645 hooks: HooksConfig::default(),
1646 lifecycle_hooks: LifecycleHooksConfig::default(),
1647 tools: ToolsConfig::default(),
1648 skills: SkillsConfig::default(),
1649 env_vars: Vec::new(),
1650 default_work_area: None,
1651 access_control: None,
1652 features: FeatureFlags::default(),
1653 defaults: None,
1654 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1655 notifications: NotificationsConfig::default(),
1656 connect: ConnectConfig::default(),
1657 plugin_trust: PluginTrustConfig::default(),
1658 extra: BTreeMap::new(),
1659 }
1660 }
1661}
1662
1663#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1666struct NetworkConfigSection {
1667 #[serde(default)]
1668 http_proxy: String,
1669 #[serde(default)]
1670 https_proxy: String,
1671 #[serde(skip_serializing)]
1672 proxy_auth: Option<ProxyAuth>,
1673 #[serde(default, skip_serializing_if = "Option::is_none")]
1674 proxy_auth_encrypted: Option<String>,
1675 #[serde(default, skip_serializing_if = "Option::is_none")]
1676 proxy_auth_credential_ref: Option<crate::CredentialRef>,
1677 #[serde(default)]
1678 headless_auth: bool,
1679 #[serde(default)]
1680 server: ServerConfig,
1681}
1682
1683#[derive(Debug, Clone, Serialize, Deserialize)]
1684struct ProviderRoutingConfigSection {
1685 #[serde(default = "default_provider")]
1686 provider: String,
1687 #[serde(default, skip_serializing_if = "Option::is_none")]
1688 defaults: Option<DefaultsConfig>,
1689 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1690 provider_instances: HashMap<String, ProviderInstanceConfig>,
1691 #[serde(default, skip_serializing_if = "Option::is_none")]
1692 default_provider_instance: Option<String>,
1693}
1694
1695impl Default for ProviderRoutingConfigSection {
1696 fn default() -> Self {
1697 Self {
1698 provider: default_provider(),
1699 defaults: None,
1700 provider_instances: HashMap::new(),
1701 default_provider_instance: None,
1702 }
1703 }
1704}
1705
1706#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1707struct ModelBehaviorConfigSection {
1708 #[serde(default)]
1709 keyword_masking: KeywordMaskingConfig,
1710 #[serde(default)]
1711 anthropic_model_mapping: AnthropicModelMapping,
1712 #[serde(default)]
1713 gemini_model_mapping: GeminiModelMapping,
1714 #[serde(default)]
1715 hooks: HooksConfig,
1716 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1717 lifecycle_hooks: LifecycleHooksConfig,
1718}
1719
1720#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1721struct ToolingConfigSection {
1722 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1723 tools: ToolsConfig,
1724 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1725 skills: SkillsConfig,
1726 #[serde(default, rename = "mcpServers", alias = "mcp")]
1727 mcp: bamboo_domain::mcp_config::McpConfig,
1728}
1729
1730#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1731struct WorkspaceConfigSection {
1732 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1733 env_vars: Vec<EnvVarEntry>,
1734 #[serde(default, skip_serializing_if = "Option::is_none")]
1735 default_work_area: Option<DefaultWorkAreaConfig>,
1736 #[serde(default, skip_serializing_if = "Option::is_none")]
1737 access_control: Option<AccessControlConfig>,
1738}
1739
1740#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1741struct ExecutionConfigSection {
1742 #[serde(default)]
1743 features: FeatureFlags,
1744 #[serde(default)]
1745 run_budget: RunBudgetConfig,
1746 #[serde(default)]
1747 stream_timeout: StreamTimeoutConfig,
1748 #[serde(
1749 default,
1750 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1751 )]
1752 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1753}
1754
1755#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1756struct IntegrationConfigSection {
1757 #[serde(default)]
1758 notifications: NotificationsConfig,
1759 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1760 connect: ConnectConfig,
1761}
1762
1763#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1764struct PluginSecurityConfigSection {
1765 #[serde(default)]
1766 plugin_trust: PluginTrustConfig,
1767}
1768
1769macro_rules! count_fields {
1772 ($($field:ident),* $(,)?) => {
1773 <[()]>::len(&[$(count_fields!(@one $field)),*])
1774 };
1775 (@one $field:ident) => { () };
1776}
1777
1778macro_rules! define_counted_struct {
1779 (
1780 $(#[$struct_meta:meta])*
1781 $visibility:vis struct $name:ident {
1782 $(
1783 $(#[$field_meta:meta])*
1784 $field_visibility:vis $field:ident: $field_type:ty
1785 ),* $(,)?
1786 }
1787 count $count_visibility:vis $count_name:ident;
1788 ) => {
1789 $(#[$struct_meta])*
1790 $visibility struct $name {
1791 $(
1792 $(#[$field_meta])*
1793 $field_visibility $field: $field_type,
1794 )*
1795 }
1796
1797 $count_visibility const $count_name: usize = count_fields!($($field),*);
1798 };
1799}
1800
1801define_counted_struct! {
1802 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
1808 struct ConfigRoot {
1809 #[serde(flatten)]
1810 network: NetworkConfigSection,
1811 #[serde(flatten)]
1812 provider_routing: ProviderRoutingConfigSection,
1813 #[serde(flatten)]
1814 model_behavior: ModelBehaviorConfigSection,
1815 #[serde(flatten)]
1816 tooling: ToolingConfigSection,
1817 #[serde(flatten)]
1818 workspace: WorkspaceConfigSection,
1819 #[serde(flatten)]
1820 execution: ExecutionConfigSection,
1821 #[serde(flatten)]
1822 integrations: IntegrationConfigSection,
1823 #[serde(flatten)]
1824 plugin_security: PluginSecurityConfigSection,
1825 #[serde(default, flatten)]
1826 extra: BTreeMap<String, Value>,
1827 }
1828 count pub PERSISTED_ROOT_FIELD_COUNT;
1829}
1830
1831impl From<ConfigValues> for ConfigRoot {
1832 fn from(values: ConfigValues) -> Self {
1833 let ConfigValues {
1836 http_proxy,
1837 https_proxy,
1838 proxy_auth,
1839 proxy_auth_encrypted,
1840 proxy_auth_credential_ref,
1841 headless_auth,
1842 provider,
1843 defaults,
1844 provider_instances,
1845 default_provider_instance,
1846 server,
1847 keyword_masking,
1848 anthropic_model_mapping,
1849 gemini_model_mapping,
1850 hooks,
1851 lifecycle_hooks,
1852 tools,
1853 skills,
1854 env_vars,
1855 default_work_area,
1856 access_control,
1857 features,
1858 run_budget,
1859 stream_timeout,
1860 cluster_fabric,
1861 mcp,
1862 notifications,
1863 connect,
1864 plugin_trust,
1865 extra,
1866 } = values;
1867
1868 Self {
1869 network: NetworkConfigSection {
1870 http_proxy,
1871 https_proxy,
1872 proxy_auth,
1873 proxy_auth_encrypted,
1874 proxy_auth_credential_ref,
1875 headless_auth,
1876 server,
1877 },
1878 provider_routing: ProviderRoutingConfigSection {
1879 provider,
1880 defaults,
1881 provider_instances,
1882 default_provider_instance,
1883 },
1884 model_behavior: ModelBehaviorConfigSection {
1885 keyword_masking,
1886 anthropic_model_mapping,
1887 gemini_model_mapping,
1888 hooks,
1889 lifecycle_hooks,
1890 },
1891 tooling: ToolingConfigSection { tools, skills, mcp },
1892 workspace: WorkspaceConfigSection {
1893 env_vars,
1894 default_work_area,
1895 access_control,
1896 },
1897 execution: ExecutionConfigSection {
1898 features,
1899 run_budget,
1900 stream_timeout,
1901 cluster_fabric,
1902 },
1903 integrations: IntegrationConfigSection {
1904 notifications,
1905 connect,
1906 },
1907 plugin_security: PluginSecurityConfigSection { plugin_trust },
1908 extra,
1909 }
1910 }
1911}
1912
1913impl From<ConfigRoot> for ConfigValues {
1914 fn from(root: ConfigRoot) -> Self {
1915 let ConfigRoot {
1918 network,
1919 provider_routing,
1920 model_behavior,
1921 tooling,
1922 workspace,
1923 execution,
1924 integrations,
1925 plugin_security,
1926 extra,
1927 } = root;
1928 let NetworkConfigSection {
1929 http_proxy,
1930 https_proxy,
1931 proxy_auth,
1932 proxy_auth_encrypted,
1933 proxy_auth_credential_ref,
1934 headless_auth,
1935 server,
1936 } = network;
1937 let ProviderRoutingConfigSection {
1938 provider,
1939 defaults,
1940 provider_instances,
1941 default_provider_instance,
1942 } = provider_routing;
1943 let ModelBehaviorConfigSection {
1944 keyword_masking,
1945 anthropic_model_mapping,
1946 gemini_model_mapping,
1947 hooks,
1948 lifecycle_hooks,
1949 } = model_behavior;
1950 let ToolingConfigSection { tools, skills, mcp } = tooling;
1951 let WorkspaceConfigSection {
1952 env_vars,
1953 default_work_area,
1954 access_control,
1955 } = workspace;
1956 let ExecutionConfigSection {
1957 features,
1958 run_budget,
1959 stream_timeout,
1960 cluster_fabric,
1961 } = execution;
1962 let IntegrationConfigSection {
1963 notifications,
1964 connect,
1965 } = integrations;
1966 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
1967
1968 Self {
1969 http_proxy,
1970 https_proxy,
1971 proxy_auth,
1972 proxy_auth_encrypted,
1973 proxy_auth_credential_ref,
1974 headless_auth,
1975 provider,
1976 defaults,
1977 provider_instances,
1978 default_provider_instance,
1979 server,
1980 keyword_masking,
1981 anthropic_model_mapping,
1982 gemini_model_mapping,
1983 hooks,
1984 lifecycle_hooks,
1985 tools,
1986 skills,
1987 env_vars,
1988 default_work_area,
1989 access_control,
1990 features,
1991 run_budget,
1992 stream_timeout,
1993 cluster_fabric,
1994 mcp,
1995 notifications,
1996 connect,
1997 plugin_trust,
1998 extra,
1999 }
2000 }
2001}
2002
2003fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2010 let instance_native = values
2011 .default_provider_instance
2012 .as_ref()
2013 .is_some_and(|id| values.provider_instances.contains_key(id));
2014 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2015 if instance_native {
2016 if let Some(object) = value.as_object_mut() {
2017 object.remove("provider");
2018 object.remove("providers");
2019 }
2020 }
2021 Ok(value)
2022}
2023
2024define_counted_struct! {
2025 #[derive(Debug, Clone)]
2028 pub struct Config {
2029 values: ConfigValues,
2030 pub(crate) memory: crate::MemoryConfigModule,
2031 pub(crate) subagents: crate::SubagentsConfigModule,
2032 pub(crate) providers: crate::ProviderConfigsModule,
2033 recovery_status: Option<ConfigRecoveryStatus>,
2034 }
2035 count pub CONFIG_FIELD_COUNT;
2036}
2037
2038pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2040const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2041const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2042
2043impl std::ops::Deref for Config {
2044 type Target = ConfigValues;
2045
2046 fn deref(&self) -> &Self::Target {
2047 &self.values
2048 }
2049}
2050
2051impl std::ops::DerefMut for Config {
2052 fn deref_mut(&mut self) -> &mut Self::Target {
2053 &mut self.values
2054 }
2055}
2056
2057impl Serialize for Config {
2058 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2059 where
2060 S: serde::Serializer,
2061 {
2062 self.to_compatibility_value()
2063 .map_err(serde::ser::Error::custom)?
2064 .serialize(serializer)
2065 }
2066}
2067
2068impl<'de> Deserialize<'de> for Config {
2069 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2070 where
2071 D: serde::Deserializer<'de>,
2072 {
2073 use serde::de::Error;
2074
2075 let mut value = Value::deserialize(deserializer)?;
2076 let object = value
2077 .as_object_mut()
2078 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2079 let memory = object
2080 .remove("memory")
2081 .map(serde_json::from_value)
2082 .transpose()
2083 .map_err(D::Error::custom)?
2084 .unwrap_or_default();
2085 let subagents = object
2086 .remove("subagents")
2087 .map(serde_json::from_value)
2088 .transpose()
2089 .map_err(D::Error::custom)?
2090 .unwrap_or_default();
2091 let providers = object
2092 .remove("providers")
2093 .map(serde_json::from_value)
2094 .transpose()
2095 .map_err(D::Error::custom)?
2096 .unwrap_or_default();
2097 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2098
2099 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2100 }
2101}
2102
2103#[derive(Debug, Clone, PartialEq, Serialize)]
2105#[serde(tag = "kind", rename_all = "snake_case")]
2106pub enum ConfigRecoverySource {
2107 Salvaged { fields: Vec<String> },
2112 Backup { generation: usize },
2115 Defaults,
2117}
2118
2119#[derive(Debug, Clone, PartialEq, Serialize)]
2131pub struct ConfigRecoveryStatus {
2132 pub source: ConfigRecoverySource,
2134 pub quarantine_path: Option<PathBuf>,
2140 pub confirmed: bool,
2143}
2144
2145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2149pub struct ProviderConfigs {
2150 #[serde(skip_serializing_if = "Option::is_none")]
2152 pub openai: Option<OpenAIConfig>,
2153 #[serde(skip_serializing_if = "Option::is_none")]
2155 pub anthropic: Option<AnthropicConfig>,
2156 #[serde(skip_serializing_if = "Option::is_none")]
2158 pub gemini: Option<GeminiConfig>,
2159 #[serde(skip_serializing_if = "Option::is_none")]
2161 pub copilot: Option<CopilotConfig>,
2162 #[serde(skip_serializing_if = "Option::is_none")]
2164 pub bodhi: Option<BodhiConfig>,
2165
2166 #[serde(default, flatten)]
2168 pub extra: BTreeMap<String, Value>,
2169}
2170
2171impl ProviderConfigs {
2172 pub fn clear_legacy_builtin_aliases(&mut self) {
2175 self.openai = None;
2176 self.anthropic = None;
2177 self.gemini = None;
2178 self.copilot = None;
2179 self.bodhi = None;
2180 self.extra.remove("provider");
2181 }
2182}
2183
2184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2186pub struct FeatureFlags {
2187 #[serde(default)]
2189 pub provider_model_ref: bool,
2190 #[serde(default)]
2192 pub dynamic_model_routing: bool,
2193}
2194
2195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2199pub struct DefaultsConfig {
2200 pub chat: bamboo_domain::ProviderModelRef,
2201 #[serde(default, skip_serializing_if = "Option::is_none")]
2202 pub fast: Option<bamboo_domain::ProviderModelRef>,
2203 #[serde(default, skip_serializing_if = "Option::is_none")]
2204 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2205 #[serde(default, skip_serializing_if = "Option::is_none")]
2206 pub vision: Option<bamboo_domain::ProviderModelRef>,
2207 #[serde(default, skip_serializing_if = "Option::is_none")]
2208 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2209 #[serde(default, skip_serializing_if = "Option::is_none")]
2212 pub planning: Option<bamboo_domain::ProviderModelRef>,
2213 #[serde(default, skip_serializing_if = "Option::is_none")]
2216 pub search: Option<bamboo_domain::ProviderModelRef>,
2217 #[serde(default, skip_serializing_if = "Option::is_none")]
2220 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2221 #[serde(
2224 default,
2225 skip_serializing_if = "Option::is_none",
2226 alias = "sub_session"
2227 )]
2228 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2229 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2233 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2234}
2235
2236#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2238pub struct HooksConfig {
2239 #[serde(default)]
2241 pub image_fallback: ImageFallbackHookConfig,
2242}
2243
2244pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2246pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2248pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2252
2253pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2255 "SessionStart",
2256 "UserPromptSubmit",
2257 "PreToolUse",
2258 "PostToolUse",
2259 "Stop",
2260 "SessionEnd",
2261 "PreCompact",
2262 "Notification",
2263];
2264
2265fn default_lifecycle_hook_timeout_ms() -> u64 {
2266 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2267}
2268
2269fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2270 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2271}
2272
2273fn lifecycle_hook_enabled_default() -> bool {
2274 true
2275}
2276
2277fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2278 *value
2279}
2280
2281#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2286pub struct LifecycleHooksConfig {
2287 #[serde(default)]
2288 pub enabled: bool,
2289 #[serde(
2290 default,
2291 rename = "SessionStart",
2292 skip_serializing_if = "Vec::is_empty"
2293 )]
2294 pub session_start: Vec<LifecycleHookGroup>,
2295 #[serde(
2296 default,
2297 rename = "UserPromptSubmit",
2298 skip_serializing_if = "Vec::is_empty"
2299 )]
2300 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2301 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2302 pub pre_tool_use: Vec<LifecycleHookGroup>,
2303 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2304 pub post_tool_use: Vec<LifecycleHookGroup>,
2305 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2306 pub stop: Vec<LifecycleHookGroup>,
2307 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2308 pub session_end: Vec<LifecycleHookGroup>,
2309 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2310 pub pre_compact: Vec<LifecycleHookGroup>,
2311 #[serde(
2312 default,
2313 rename = "Notification",
2314 skip_serializing_if = "Vec::is_empty"
2315 )]
2316 pub notification: Vec<LifecycleHookGroup>,
2317}
2318
2319impl LifecycleHooksConfig {
2320 pub fn is_empty(&self) -> bool {
2321 !self.enabled
2322 && self.session_start.is_empty()
2323 && self.user_prompt_submit.is_empty()
2324 && self.pre_tool_use.is_empty()
2325 && self.post_tool_use.is_empty()
2326 && self.stop.is_empty()
2327 && self.session_end.is_empty()
2328 && self.pre_compact.is_empty()
2329 && self.notification.is_empty()
2330 }
2331}
2332
2333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2335pub struct LifecycleHookGroup {
2336 #[serde(
2339 default = "lifecycle_hook_enabled_default",
2340 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2341 )]
2342 pub enabled: bool,
2343 #[serde(default, skip_serializing_if = "Option::is_none")]
2344 pub matcher: Option<String>,
2345 #[serde(default)]
2346 pub hooks: Vec<LifecycleHookHandler>,
2347}
2348
2349impl Default for LifecycleHookGroup {
2350 fn default() -> Self {
2351 Self {
2352 enabled: true,
2353 matcher: None,
2354 hooks: Vec::new(),
2355 }
2356 }
2357}
2358
2359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2364#[serde(tag = "type", rename_all = "lowercase")]
2365pub enum LifecycleHookHandler {
2366 Command {
2367 command: String,
2368 #[serde(
2369 default = "default_lifecycle_hook_timeout_ms",
2370 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2371 )]
2372 timeout_ms: u64,
2373 },
2374 Script {
2375 path: String,
2376 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2377 runner: LifecycleScriptRunner,
2378 #[serde(
2379 default = "default_lifecycle_hook_timeout_ms",
2380 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2381 )]
2382 timeout_ms: u64,
2383 },
2384}
2385
2386#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2392#[serde(rename_all = "lowercase")]
2393pub enum LifecycleScriptRunner {
2394 #[default]
2395 Auto,
2396 Node,
2397 Bun,
2398 Python,
2399 Bash,
2400 PowerShell,
2401 Cmd,
2402}
2403
2404impl LifecycleScriptRunner {
2405 pub fn is_auto(&self) -> bool {
2406 matches!(self, Self::Auto)
2407 }
2408
2409 pub fn as_str(self) -> &'static str {
2410 match self {
2411 Self::Auto => "auto",
2412 Self::Node => "node",
2413 Self::Bun => "bun",
2414 Self::Python => "python",
2415 Self::Bash => "bash",
2416 Self::PowerShell => "powershell",
2417 Self::Cmd => "cmd",
2418 }
2419 }
2420
2421 pub fn supports_path(self, path: &str) -> bool {
2423 let extension = lifecycle_script_extension(path);
2424 match self {
2425 Self::Auto => extension.is_some(),
2426 Self::Node | Self::Bun => {
2427 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2428 }
2429 Self::Python => matches!(extension.as_deref(), Some("py")),
2430 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2431 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2432 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2433 }
2434 }
2435}
2436
2437pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2440 let extension = std::path::Path::new(path)
2441 .extension()?
2442 .to_str()?
2443 .to_ascii_lowercase();
2444 matches!(
2445 extension.as_str(),
2446 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2447 )
2448 .then_some(extension)
2449}
2450
2451impl LifecycleHookHandler {
2452 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2453 Self::Command {
2454 command: command.into(),
2455 timeout_ms,
2456 }
2457 }
2458
2459 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2460 Self::Script {
2461 path: path.into(),
2462 runner,
2463 timeout_ms,
2464 }
2465 }
2466
2467 pub fn timeout_ms(&self) -> u64 {
2468 match self {
2469 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2470 }
2471 }
2472}
2473
2474#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2481pub struct RequestOverridesConfig {
2482 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2484 pub common: RequestScopeOverride,
2485 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2487 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2490 pub rules: Vec<ModelRequestRule>,
2491}
2492
2493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2495pub struct ModelRequestRule {
2496 pub model_pattern: String,
2498 #[serde(default, skip_serializing_if = "Option::is_none")]
2500 pub endpoint: Option<String>,
2501 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2503 pub scope: RequestScopeOverride,
2504}
2505
2506#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2508pub struct RequestScopeOverride {
2509 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2511 pub headers: BTreeMap<String, TemplateExpr>,
2512 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2514 pub body_patch: Vec<BodyPatch>,
2515}
2516
2517impl RequestScopeOverride {
2518 pub fn is_empty(&self) -> bool {
2519 self.headers.is_empty() && self.body_patch.is_empty()
2520 }
2521}
2522
2523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2525pub struct BodyPatch {
2526 pub path: String,
2528 #[serde(default)]
2530 pub op: BodyPatchOp,
2531 #[serde(default, skip_serializing_if = "Option::is_none")]
2533 pub value: Option<PatchValue>,
2534}
2535
2536#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2538#[serde(rename_all = "snake_case")]
2539pub enum BodyPatchOp {
2540 #[default]
2541 Set,
2542 Remove,
2543}
2544
2545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2547#[serde(untagged)]
2548pub enum PatchValue {
2549 Template(TemplateExpr),
2550 Json(Value),
2551}
2552
2553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2555#[serde(untagged)]
2556pub enum TemplateExpr {
2557 Literal(String),
2559 Structured(TemplateExprSpec),
2561}
2562
2563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2565#[serde(tag = "type", rename_all = "snake_case")]
2566pub enum TemplateExprSpec {
2567 Literal { value: String },
2569 EnvRef {
2571 name: String,
2572 #[serde(default, skip_serializing_if = "Option::is_none")]
2573 fallback: Option<String>,
2574 },
2575 Generated { generator: GeneratedValue },
2577 Format { template: String },
2579}
2580
2581#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2583#[serde(rename_all = "snake_case")]
2584pub enum GeneratedValue {
2585 Uuid,
2586 UnixMs,
2587}
2588
2589#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2591pub struct ToolsConfig {
2592 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2594 pub disabled: Vec<String>,
2595
2596 #[serde(default, flatten)]
2598 pub extra: BTreeMap<String, Value>,
2599}
2600
2601impl ToolsConfig {
2602 fn is_empty(&self) -> bool {
2603 self.disabled.is_empty() && self.extra.is_empty()
2604 }
2605}
2606
2607#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2609pub struct SkillsConfig {
2610 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2612 pub disabled: Vec<String>,
2613
2614 #[serde(default, flatten)]
2616 pub extra: BTreeMap<String, Value>,
2617}
2618
2619impl SkillsConfig {
2620 fn is_empty(&self) -> bool {
2621 self.disabled.is_empty() && self.extra.is_empty()
2622 }
2623}
2624
2625#[derive(Debug, Clone, Serialize, Deserialize)]
2630pub struct ImageFallbackHookConfig {
2631 #[serde(default = "default_true_hooks")]
2632 pub enabled: bool,
2633
2634 #[serde(default = "default_image_fallback_mode")]
2636 pub mode: String,
2637}
2638
2639impl Default for ImageFallbackHookConfig {
2640 fn default() -> Self {
2641 Self {
2642 enabled: default_true_hooks(),
2643 mode: default_image_fallback_mode(),
2644 }
2645 }
2646}
2647
2648fn default_image_fallback_mode() -> String {
2649 "placeholder".to_string()
2650}
2651
2652fn default_true_hooks() -> bool {
2653 false
2656}
2657
2658pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2670
2671#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2672pub struct OpenAIConfig {
2673 #[serde(default, skip_serializing)]
2677 pub api_key: String,
2678 #[serde(default, skip_serializing_if = "Option::is_none")]
2680 pub api_key_encrypted: Option<String>,
2681 #[serde(default, skip_serializing_if = "Option::is_none")]
2683 pub credential_ref: Option<crate::CredentialRef>,
2684 #[serde(skip)]
2689 pub api_key_from_env: bool,
2690 #[serde(skip_serializing_if = "Option::is_none")]
2692 pub base_url: Option<String>,
2693 #[serde(skip_serializing_if = "Option::is_none")]
2695 pub model: Option<String>,
2696 #[serde(default, skip_serializing_if = "Option::is_none")]
2699 pub fast_model: Option<String>,
2700 #[serde(default, skip_serializing_if = "Option::is_none")]
2703 pub vision_model: Option<String>,
2704 #[serde(skip_serializing_if = "Option::is_none")]
2706 pub reasoning_effort: Option<ReasoningEffort>,
2707
2708 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2715 pub responses_only_models: Vec<String>,
2716 #[serde(default, skip_serializing_if = "Option::is_none")]
2718 pub request_overrides: Option<RequestOverridesConfig>,
2719
2720 #[serde(default, flatten)]
2722 pub extra: BTreeMap<String, Value>,
2723}
2724
2725impl OpenAIConfig {
2726 pub fn explicit_prompt_cache_enabled(&self) -> bool {
2733 self.extra
2734 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
2735 .and_then(Value::as_bool)
2736 .unwrap_or(true)
2737 }
2738}
2739
2740#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2752pub struct AnthropicConfig {
2753 #[serde(default, skip_serializing)]
2757 pub api_key: String,
2758 #[serde(default, skip_serializing_if = "Option::is_none")]
2760 pub api_key_encrypted: Option<String>,
2761 #[serde(default, skip_serializing_if = "Option::is_none")]
2763 pub credential_ref: Option<crate::CredentialRef>,
2764 #[serde(skip)]
2769 pub api_key_from_env: bool,
2770 #[serde(skip_serializing_if = "Option::is_none")]
2772 pub base_url: Option<String>,
2773 #[serde(skip_serializing_if = "Option::is_none")]
2775 pub model: Option<String>,
2776 #[serde(default, skip_serializing_if = "Option::is_none")]
2779 pub fast_model: Option<String>,
2780 #[serde(default, skip_serializing_if = "Option::is_none")]
2783 pub vision_model: Option<String>,
2784 #[serde(skip_serializing_if = "Option::is_none")]
2786 pub max_tokens: Option<u32>,
2787 #[serde(skip_serializing_if = "Option::is_none")]
2789 pub reasoning_effort: Option<ReasoningEffort>,
2790 #[serde(default, skip_serializing_if = "Option::is_none")]
2792 pub request_overrides: Option<RequestOverridesConfig>,
2793
2794 #[serde(default, skip_serializing_if = "Option::is_none")]
2811 pub thinking_replay_always: Option<bool>,
2812
2813 #[serde(default, flatten)]
2815 pub extra: BTreeMap<String, Value>,
2816}
2817
2818#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2829pub struct GeminiConfig {
2830 #[serde(default, skip_serializing)]
2834 pub api_key: String,
2835 #[serde(default, skip_serializing_if = "Option::is_none")]
2837 pub api_key_encrypted: Option<String>,
2838 #[serde(default, skip_serializing_if = "Option::is_none")]
2840 pub credential_ref: Option<crate::CredentialRef>,
2841 #[serde(skip)]
2846 pub api_key_from_env: bool,
2847 #[serde(skip_serializing_if = "Option::is_none")]
2849 pub base_url: Option<String>,
2850 #[serde(skip_serializing_if = "Option::is_none")]
2852 pub model: Option<String>,
2853 #[serde(default, skip_serializing_if = "Option::is_none")]
2856 pub fast_model: Option<String>,
2857 #[serde(default, skip_serializing_if = "Option::is_none")]
2860 pub vision_model: Option<String>,
2861 #[serde(skip_serializing_if = "Option::is_none")]
2863 pub reasoning_effort: Option<ReasoningEffort>,
2864 #[serde(default, skip_serializing_if = "Option::is_none")]
2866 pub request_overrides: Option<RequestOverridesConfig>,
2867
2868 #[serde(default, flatten)]
2870 pub extra: BTreeMap<String, Value>,
2871}
2872
2873#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2885pub struct CopilotConfig {
2886 #[serde(default)]
2888 pub enabled: bool,
2889 #[serde(default)]
2891 pub headless_auth: bool,
2892 #[serde(skip_serializing_if = "Option::is_none")]
2894 pub model: Option<String>,
2895 #[serde(default, skip_serializing_if = "Option::is_none")]
2898 pub fast_model: Option<String>,
2899 #[serde(default, skip_serializing_if = "Option::is_none")]
2902 pub vision_model: Option<String>,
2903 #[serde(skip_serializing_if = "Option::is_none")]
2905 pub reasoning_effort: Option<ReasoningEffort>,
2906
2907 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2916 pub responses_only_models: Vec<String>,
2917 #[serde(default, skip_serializing_if = "Option::is_none")]
2919 pub request_overrides: Option<RequestOverridesConfig>,
2920
2921 #[serde(default, flatten)]
2923 pub extra: BTreeMap<String, Value>,
2924}
2925
2926#[derive(Debug, Clone, Serialize, Deserialize)]
2931pub struct BodhiConfig {
2932 #[serde(default, skip_serializing)]
2934 pub api_key: String,
2935 #[serde(default, skip_serializing_if = "Option::is_none")]
2937 pub api_key_encrypted: Option<String>,
2938 #[serde(default, skip_serializing_if = "Option::is_none")]
2940 pub credential_ref: Option<crate::CredentialRef>,
2941 #[serde(skip_serializing_if = "Option::is_none")]
2943 pub base_url: Option<String>,
2944 #[serde(skip_serializing_if = "Option::is_none")]
2946 pub target_provider: Option<String>,
2947 #[serde(skip_serializing_if = "Option::is_none")]
2949 pub reasoning_effort: Option<ReasoningEffort>,
2950
2951 #[serde(default, flatten)]
2953 pub extra: BTreeMap<String, Value>,
2954}
2955
2956fn default_provider() -> String {
2958 "anthropic".to_string()
2959}
2960
2961#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2993pub struct ProviderInstanceConfig {
2994 pub provider_type: String,
2999
3000 #[serde(default, skip_serializing_if = "Option::is_none")]
3002 pub label: Option<String>,
3003
3004 #[serde(default, skip_serializing)]
3006 pub api_key: String,
3007
3008 #[serde(default, skip_serializing_if = "Option::is_none")]
3011 pub api_key_encrypted: Option<String>,
3012
3013 #[serde(default, skip_serializing_if = "Option::is_none")]
3015 pub credential_ref: Option<crate::CredentialRef>,
3016
3017 #[serde(default, skip_serializing_if = "Option::is_none")]
3019 pub base_url: Option<String>,
3020
3021 #[serde(default, skip_serializing_if = "Option::is_none")]
3023 pub model: Option<String>,
3024
3025 #[serde(default, skip_serializing_if = "Option::is_none")]
3027 pub fast_model: Option<String>,
3028
3029 #[serde(default, skip_serializing_if = "Option::is_none")]
3031 pub vision_model: Option<String>,
3032
3033 #[serde(default, skip_serializing_if = "Option::is_none")]
3035 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3036
3037 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3039 pub responses_only_models: Vec<String>,
3040
3041 #[serde(default, skip_serializing_if = "Option::is_none")]
3043 pub request_overrides: Option<RequestOverridesConfig>,
3044
3045 #[serde(default = "default_true")]
3048 pub enabled: bool,
3049
3050 #[serde(default, flatten)]
3052 pub extra: BTreeMap<String, Value>,
3053}
3054
3055fn default_true() -> bool {
3056 true
3057}
3058
3059fn default_port() -> u16 {
3061 9562
3062}
3063
3064fn default_bind() -> String {
3066 "127.0.0.1".to_string()
3067}
3068
3069fn default_workers() -> usize {
3071 10
3072}
3073
3074fn default_data_dir() -> PathBuf {
3076 super::paths::bamboo_dir()
3077}
3078
3079#[derive(Debug, Clone, Serialize, Deserialize)]
3081pub struct ServerConfig {
3082 #[serde(default = "default_port")]
3084 pub port: u16,
3085
3086 #[serde(default = "default_bind")]
3088 pub bind: String,
3089
3090 pub static_dir: Option<PathBuf>,
3092
3093 #[serde(default = "default_workers")]
3095 pub workers: usize,
3096
3097 #[serde(default, skip_serializing_if = "Option::is_none")]
3105 pub tls: Option<TlsConfig>,
3106
3107 #[serde(default, flatten)]
3109 pub extra: BTreeMap<String, Value>,
3110}
3111
3112impl Default for ServerConfig {
3113 fn default() -> Self {
3114 Self {
3115 port: default_port(),
3116 bind: default_bind(),
3117 static_dir: None,
3118 workers: default_workers(),
3119 tls: None,
3120 extra: BTreeMap::new(),
3121 }
3122 }
3123}
3124
3125#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3131pub struct TlsConfig {
3132 pub cert_file: PathBuf,
3134 pub key_file: PathBuf,
3136}
3137
3138#[derive(Debug, Clone, Serialize, Deserialize)]
3140pub struct ProxyAuth {
3141 pub username: String,
3143 pub password: String,
3145}
3146
3147fn parse_bool_env(value: &str) -> bool {
3151 matches!(
3152 value.trim().to_ascii_lowercase().as_str(),
3153 "1" | "true" | "yes" | "y" | "on"
3154 )
3155}
3156
3157fn expand_user_path(value: &str) -> PathBuf {
3158 let trimmed = value.trim();
3159 if let Some(rest) = trimmed.strip_prefix("~/") {
3160 if let Some(home) = dirs::home_dir() {
3161 return home.join(rest);
3162 }
3163 }
3164 PathBuf::from(trimmed)
3165}
3166
3167impl Default for Config {
3168 fn default() -> Self {
3169 Self::create_default()
3178 }
3179}
3180
3181#[derive(Debug, Clone, PartialEq, Eq)]
3183pub struct PromptSafeEnvVarEntry {
3184 pub name: String,
3185 pub secret: bool,
3186 pub description: Option<String>,
3187}
3188
3189static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3193
3194static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3195
3196fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3197 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3198}
3199
3200fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3201 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3202}
3203
3204impl Config {
3205 fn from_parts(
3206 values: ConfigValues,
3207 memory: Option<MemoryConfig>,
3208 subagents: SubagentsConfig,
3209 providers: ProviderConfigs,
3210 ) -> Self {
3211 Self {
3212 values,
3213 memory: crate::MemoryConfigModule(memory),
3214 subagents: crate::SubagentsConfigModule(subagents),
3215 providers: crate::ProviderConfigsModule(providers),
3216 recovery_status: None,
3217 }
3218 }
3219
3220 pub(crate) fn section_values(&self) -> ConfigValues {
3226 self.values.clone()
3227 }
3228
3229 pub(crate) fn from_section_parts(
3230 values: ConfigValues,
3231 memory: Option<MemoryConfig>,
3232 subagents: SubagentsConfig,
3233 providers: ProviderConfigs,
3234 ) -> Self {
3235 Self::from_parts(values, memory, subagents, providers)
3236 }
3237
3238 pub fn memory(&self) -> &Option<MemoryConfig> {
3240 &self.memory.0
3241 }
3242
3243 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3244 &mut self.memory.0
3245 }
3246
3247 pub fn subagents(&self) -> &SubagentsConfig {
3249 &self.subagents.0
3250 }
3251
3252 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3253 &mut self.subagents.0
3254 }
3255
3256 pub fn providers(&self) -> &ProviderConfigs {
3258 &self.providers.0
3259 }
3260
3261 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3262 &mut self.providers.0
3263 }
3264
3265 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3269 if self
3270 .default_provider_instance
3271 .as_ref()
3272 .is_some_and(|id| self.provider_instances.contains_key(id))
3273 {
3274 self.providers.0.clear_legacy_builtin_aliases();
3275 }
3276 }
3277
3278 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3284 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3285 let object = value
3286 .as_object_mut()
3287 .expect("ConfigRoot always serializes as a JSON object");
3288 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3289 object.insert(
3290 "subagents".to_string(),
3291 serde_json::to_value(self.subagents())?,
3292 );
3293 object.insert(
3294 "providers".to_string(),
3295 serde_json::to_value(self.providers())?,
3296 );
3297 Ok(value)
3298 }
3299
3300 pub fn new() -> Self {
3319 Self::from_data_dir(None)
3320 }
3321
3322 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3323 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3324 Ok(facade) => facade.effective_config(),
3325 Err(error) => {
3326 tracing::warn!(
3327 error = %error,
3328 "completed modular configuration is unavailable; refusing legacy-root fallback"
3329 );
3330 Self::create_default()
3331 }
3332 };
3333
3334 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3335 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3336 config.proxy_auth = None;
3337 }
3338 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3339 tracing::warn!(error = %error, "provider credential hydration unavailable");
3340 }
3341 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3342 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3343 }
3344 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3345 tracing::warn!(error = %error, "env credential hydration unavailable");
3346 for entry in &mut config.env_vars {
3347 if entry.secret {
3348 entry.value.clear();
3349 }
3350 }
3351 }
3352 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3353 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3354 config.clear_cluster_runtime_credentials();
3355 }
3356 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3357 tracing::warn!(error = %error, "notification credential hydration unavailable");
3358 config.notifications.ntfy.token = None;
3359 config.notifications.bark.device_key = None;
3360 }
3361 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3362 tracing::warn!(error = %error, "connect credential hydration unavailable");
3363 for platform in &mut config.connect.platforms {
3364 platform.token = None;
3365 platform.app_secret = None;
3366 }
3367 }
3368 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3369 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3370 config.clear_access_control_runtime_verifiers();
3371 }
3372 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3373 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3374 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3375 broker.token.clear();
3376 }
3377 }
3378 config.normalize_tool_settings();
3379 config.normalize_skill_settings();
3380 config.normalize_plugin_trust_settings();
3381 config.extra.remove("data_dir");
3382 if apply_env {
3383 config.apply_env_overrides();
3384 }
3385 if publish {
3386 config.publish_env_vars();
3387 }
3388 config
3389 }
3390
3391 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3400 let data_dir = data_dir
3402 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3403 .unwrap_or_else(default_data_dir);
3404
3405 match crate::modular_authority_boundary_present(&data_dir) {
3406 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3407 Ok(false) => {}
3408 Err(error) => {
3409 tracing::warn!(
3410 error = %error,
3411 "modular configuration marker is unavailable; refusing legacy-root fallback"
3412 );
3413 let mut config = Self::create_default();
3414 if apply_env {
3415 config.apply_env_overrides();
3416 }
3417 if publish {
3418 config.publish_env_vars();
3419 }
3420 return config;
3421 }
3422 }
3423
3424 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3429 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3430 .map_err(|error| {
3431 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3432 error
3433 })
3434 .is_ok();
3435 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3436 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3437 .map_err(|error| {
3438 tracing::warn!(error = %error, "cluster credential migration unavailable");
3439 error
3440 })
3441 .is_ok();
3442
3443 match crate::modular_authority_boundary_present(&data_dir) {
3448 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3449 Ok(false) => {}
3450 Err(error) => {
3451 tracing::warn!(
3452 error = %error,
3453 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3454 );
3455 let mut config = Self::create_default();
3456 if apply_env {
3457 config.apply_env_overrides();
3458 }
3459 if publish {
3460 config.publish_env_vars();
3461 }
3462 return config;
3463 }
3464 }
3465
3466 let config_path = data_dir.join("config.json");
3467
3468 let mut config = if config_path.exists() {
3469 if let Ok(content) = std::fs::read_to_string(&config_path) {
3470 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3471 tracing::warn!(
3481 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3482 e
3483 );
3484 let quarantine_path = quarantine_corrupt_config(&config_path);
3485 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3486 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3487 .or_else(|| {
3488 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3489 (cfg, ConfigRecoverySource::Backup { generation })
3490 })
3491 })
3492 .unwrap_or_else(|| {
3493 tracing::warn!(
3494 "Could not salvage and no usable config.json.bak; using defaults"
3495 );
3496 (Self::create_default(), ConfigRecoverySource::Defaults)
3497 });
3498 recovered.recovery_status = Some(ConfigRecoveryStatus {
3499 source,
3500 quarantine_path,
3501 confirmed: false,
3502 });
3503 recovered
3504 })
3505 } else {
3506 Self::create_default()
3507 }
3508 } else {
3509 Self::create_default()
3510 };
3511
3512 let mut memory_module = config.memory.clone();
3518 match memory_module.load_sync(&data_dir) {
3519 Ok(true) => config.memory = memory_module,
3520 Ok(false) => {}
3521 Err(error) => tracing::warn!(
3522 "Failed to load memory.json; using legacy config.json memory: {error}"
3523 ),
3524 }
3525 let mut subagents_module = config.subagents.clone();
3526 match subagents_module.load_sync(&data_dir) {
3527 Ok(true) => config.subagents = subagents_module,
3528 Ok(false) => {}
3529 Err(error) => tracing::warn!(
3530 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3531 ),
3532 }
3533 if provider_mcp_ready {
3534 let mut providers_module = config.providers.clone();
3535 match providers_module.load_sync(&data_dir) {
3536 Ok(true) => config.providers = providers_module,
3537 Ok(false) => {}
3538 Err(error) => tracing::warn!(
3539 "Failed to load providers.json; using legacy config.json providers: {error}"
3540 ),
3541 }
3542 }
3543
3544 config.hydrate_proxy_auth_from_encrypted();
3546 if provider_mcp_ready {
3547 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3548 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3549 config.proxy_auth = None;
3550 }
3551 }
3552 config.hydrate_provider_api_keys_from_encrypted();
3554 config.hydrate_provider_instance_api_keys_from_encrypted();
3556 config.hydrate_mcp_secrets_from_encrypted();
3558 if provider_mcp_ready {
3559 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3560 tracing::warn!(error = %error, "provider credential hydration unavailable");
3561 }
3562 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3563 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3564 }
3565 }
3566 config.hydrate_env_vars_from_encrypted();
3568 if provider_mcp_ready {
3569 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3570 tracing::warn!(error = %error, "env credential hydration unavailable");
3571 for entry in &mut config.env_vars {
3572 if entry.secret {
3573 entry.value.clear();
3574 }
3575 }
3576 }
3577 }
3578 if cluster_ready {
3583 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3584 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3585 config.clear_cluster_runtime_credentials();
3586 }
3587 } else {
3588 config.clear_cluster_runtime_credentials();
3589 }
3590 config.hydrate_broker_token_from_encrypted();
3592 config.hydrate_notifications_from_encrypted();
3594 if provider_mcp_ready {
3595 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3596 tracing::warn!(error = %error, "notification credential hydration unavailable");
3597 config.notifications.ntfy.token = None;
3598 config.notifications.bark.device_key = None;
3599 }
3600 } else {
3601 config.notifications.ntfy.token = None;
3606 config.notifications.bark.device_key = None;
3607 }
3608 config.merge_connect_config(&data_dir);
3613 scrub_legacy_connect_from_config_backups(&data_dir);
3623 config.hydrate_connect_platform_tokens_from_encrypted();
3625 if provider_mcp_ready {
3626 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3627 tracing::warn!(error = %error, "connect credential hydration unavailable");
3628 for platform in &mut config.connect.platforms {
3629 platform.token = None;
3630 platform.app_secret = None;
3631 }
3632 }
3633 }
3634 if provider_mcp_ready {
3635 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3636 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3637 config.clear_access_control_runtime_verifiers();
3638 }
3639 } else {
3640 config.clear_access_control_runtime_verifiers();
3641 }
3642 config.normalize_tool_settings();
3643 config.normalize_skill_settings();
3644 config.normalize_plugin_trust_settings();
3645
3646 config.extra.remove("data_dir");
3649
3650 if apply_env {
3654 config.apply_env_overrides();
3655 }
3656
3657 if publish {
3661 config.publish_env_vars();
3662 }
3663
3664 config
3665 }
3666
3667 fn apply_env_overrides(&mut self) {
3671 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3672 if let Ok(port) = port.parse() {
3673 self.server.port = port;
3674 }
3675 }
3676
3677 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3678 self.server.bind = bind;
3679 }
3680
3681 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3686 let provider = provider.trim().to_string();
3687 if !provider.is_empty() {
3688 self.provider = provider.clone();
3689 if !self.provider_instances.is_empty() {
3690 let selected = self
3691 .provider_instances
3692 .contains_key(&provider)
3693 .then_some(provider.clone())
3694 .or_else(|| {
3695 let mut matching = self
3696 .provider_instances
3697 .iter()
3698 .filter(|(_, instance)| {
3699 instance.enabled && instance.provider_type == provider
3700 })
3701 .map(|(id, _)| id.clone())
3702 .collect::<Vec<_>>();
3703 matching.sort();
3704 matching.into_iter().next()
3705 });
3706 self.default_provider_instance = selected;
3707 }
3708 }
3709 }
3710
3711 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
3712 self.headless_auth = parse_bool_env(&headless);
3713 }
3714
3715 if let Ok(project_prompt_injection) =
3716 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
3717 {
3718 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3719 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
3720 }
3721
3722 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
3723 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3724 memory.relevant_recall = parse_bool_env(&relevant_recall);
3725 }
3726
3727 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
3728 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3729 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
3730 }
3731
3732 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
3733 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3734 memory.project_first_dream = parse_bool_env(&project_first_dream);
3735 }
3736
3737 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
3744 let key = key.trim();
3745 if !key.is_empty() {
3746 if self.provider_instances.is_empty() {
3747 let openai = self
3748 .providers
3749 .openai
3750 .get_or_insert_with(OpenAIConfig::default);
3751 openai.api_key = key.to_string();
3752 openai.api_key_from_env = true;
3753 } else {
3754 self.apply_provider_instance_env_key("openai", key);
3755 }
3756 }
3757 }
3758 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
3759 let key = key.trim();
3760 if !key.is_empty() {
3761 if self.provider_instances.is_empty() {
3762 let anthropic = self
3763 .providers
3764 .anthropic
3765 .get_or_insert_with(AnthropicConfig::default);
3766 anthropic.api_key = key.to_string();
3767 anthropic.api_key_from_env = true;
3768 } else {
3769 self.apply_provider_instance_env_key("anthropic", key);
3770 }
3771 }
3772 }
3773 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
3774 let key = key.trim();
3775 if !key.is_empty() {
3776 if self.provider_instances.is_empty() {
3777 let gemini = self
3778 .providers
3779 .gemini
3780 .get_or_insert_with(GeminiConfig::default);
3781 gemini.api_key = key.to_string();
3782 gemini.api_key_from_env = true;
3783 } else {
3784 self.apply_provider_instance_env_key("gemini", key);
3785 }
3786 }
3787 }
3788 }
3789
3790 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
3791 for instance in self.provider_instances.values_mut().filter(|instance| {
3792 instance.provider_type == provider_type
3793 && crate::provider_instance_api_key_from_env(instance)
3794 }) {
3795 instance.api_key = key.to_string();
3796 instance.api_key_encrypted = None;
3797 }
3798 }
3799
3800 pub fn apply_runtime_env_overrides(&mut self) {
3805 self.apply_env_overrides();
3806 }
3807
3808 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
3814 Self::from_data_dir_impl(data_dir, true, true)
3815 }
3816
3817 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
3821 Self::from_data_dir_impl(data_dir, false, true)
3822 }
3823
3824 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
3831 Self::from_data_dir_impl(data_dir, false, false)
3832 }
3833
3834 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
3862 let connect_path = data_dir.join("connect.json");
3863 match std::fs::read_to_string(&connect_path) {
3864 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
3865 Ok(connect) => {
3866 let legacy_key_present = !connect_config_is_empty(&self.connect);
3867 if legacy_key_present {
3868 tracing::warn!(
3869 "config.json still has a legacy `connect` key alongside \
3870 connect.json; connect.json takes precedence — dropping the \
3871 stale key from config.json now"
3872 );
3873 }
3874 self.connect = connect;
3875 if legacy_key_present {
3876 strip_legacy_connect_key_from_config_json(data_dir);
3877 }
3878 }
3879 Err(e) => {
3880 tracing::error!(
3881 "Failed to parse {:?} ({}); continuing with an empty (inert) \
3882 connect config instead of falling back to a legacy config.json copy",
3883 connect_path,
3884 e
3885 );
3886 quarantine_corrupt_connect(&connect_path);
3887 self.connect = ConnectConfig::default();
3888 }
3889 },
3890 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
3891 if !connect_config_is_empty(&self.connect) {
3892 tracing::info!(
3893 "Migrating legacy `connect` config from config.json to a \
3894 standalone connect.json"
3895 );
3896 strip_legacy_connect_key_from_config_json(data_dir);
3900 if let Err(e) = save_connect_config(&self.connect, data_dir) {
3901 tracing::error!("Failed to write connect.json during migration: {}", e);
3902 }
3903 }
3904 }
3905 Err(e) => {
3906 tracing::error!(
3907 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
3908 connect_path,
3909 e
3910 );
3911 self.connect = ConnectConfig::default();
3912 }
3913 }
3914 }
3915
3916 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
3919 serde_json::from_str::<Config>(content).map(|mut config| {
3920 config.hydrate_proxy_auth_from_encrypted();
3921 config.hydrate_provider_api_keys_from_encrypted();
3922 config.hydrate_provider_instance_api_keys_from_encrypted();
3923 config.hydrate_mcp_secrets_from_encrypted();
3924 config.hydrate_env_vars_from_encrypted();
3925 config.hydrate_cluster_fabric_from_encrypted();
3926 config.hydrate_broker_token_from_encrypted();
3927 config.hydrate_notifications_from_encrypted();
3928 config.hydrate_connect_platform_tokens_from_encrypted();
3929 config.normalize_tool_settings();
3930 config.normalize_skill_settings();
3931 config
3932 })
3933 }
3934
3935 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
3941 let config_path = data_dir.join("config.json");
3942 for gen in 0..BAK_GENERATIONS {
3943 let backup = backup_path_for(&config_path, gen);
3944 let Ok(content) = std::fs::read_to_string(&backup) else {
3945 continue;
3946 };
3947 match Self::parse_and_hydrate(&content) {
3948 Ok(config) => {
3949 tracing::info!("Recovered configuration from {:?}", backup);
3950 return Some((config, gen));
3951 }
3952 Err(e) => {
3953 tracing::warn!(
3954 "Backup {:?} is unparseable ({}); trying an older generation",
3955 backup,
3956 e
3957 );
3958 }
3959 }
3960 }
3961 None
3962 }
3963
3964 const SALVAGE_MAX_KEYS: usize = 512;
3970
3971 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
3998 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4001 let corrupt_obj = corrupt.as_object()?;
4002 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4003 tracing::warn!(
4004 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4005 corrupt_obj.len(),
4006 Self::SALVAGE_MAX_KEYS
4007 );
4008 return None;
4009 }
4010
4011 let mut base = Self::load_backup(data_dir)
4015 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4016 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4017 let base_obj = base.as_object_mut()?;
4018
4019 let mut salvaged: Vec<String> = Vec::new();
4020 for (key, value) in corrupt_obj {
4021 let previous = base_obj.insert(key.clone(), value.clone());
4022 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4026 salvaged.push(key.clone());
4027 } else {
4028 match previous {
4029 Some(prev) => {
4030 base_obj.insert(key.clone(), prev);
4031 }
4032 None => {
4033 base_obj.remove(key);
4034 }
4035 }
4036 }
4037 }
4038
4039 tracing::warn!(
4040 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4041 last-known-good/default value",
4042 salvaged.len(),
4043 salvaged.join(", ")
4044 );
4045
4046 let rebuilt = serde_json::to_string(&base).ok()?;
4050 Self::parse_and_hydrate(&rebuilt)
4051 .ok()
4052 .map(|config| (config, salvaged))
4053 }
4054
4055 pub fn get_model(&self) -> Option<String> {
4063 if self.features.provider_model_ref {
4064 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4065 return Some(model_ref.model.clone());
4066 }
4067 }
4068 let provider = self.effective_default_provider();
4069 if let Some(instance) = self.provider_instances.get(provider) {
4070 return instance
4071 .model
4072 .clone()
4073 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4074 }
4075 match provider {
4076 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4077 "anthropic" => self
4078 .providers
4079 .anthropic
4080 .as_ref()
4081 .and_then(|c| c.model.clone()),
4082 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4083 "copilot" => Some(
4084 self.providers
4085 .copilot
4086 .as_ref()
4087 .and_then(|c| c.model.clone())
4088 .unwrap_or_else(|| "gpt-4o".to_string()),
4089 ),
4090 _ => None,
4091 }
4092 }
4093
4094 pub fn get_fast_model(&self) -> Option<String> {
4102 if self.features.provider_model_ref {
4103 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4104 return Some(model_ref.model.clone());
4105 }
4106 }
4107 let provider = self.effective_default_provider();
4108 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4109 instance.fast_model.clone()
4110 } else {
4111 match provider {
4112 "openai" => self
4113 .providers
4114 .openai
4115 .as_ref()
4116 .and_then(|c| c.fast_model.clone()),
4117 "anthropic" => self
4118 .providers
4119 .anthropic
4120 .as_ref()
4121 .and_then(|c| c.fast_model.clone()),
4122 "gemini" => self
4123 .providers
4124 .gemini
4125 .as_ref()
4126 .and_then(|c| c.fast_model.clone()),
4127 "copilot" => self
4128 .providers
4129 .copilot
4130 .as_ref()
4131 .and_then(|c| c.fast_model.clone()),
4132 _ => None,
4133 }
4134 };
4135 fast.or_else(|| self.get_model())
4136 }
4137
4138 pub fn get_task_summary_model(&self) -> Option<String> {
4146 if self.features.provider_model_ref {
4147 if let Some(model_ref) = self
4148 .defaults
4149 .as_ref()
4150 .and_then(|d| d.task_summary.as_ref())
4151 .or_else(|| {
4152 self.defaults
4153 .as_ref()
4154 .and_then(|d| d.memory_background.as_ref())
4155 })
4156 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4157 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4158 {
4159 return Some(model_ref.model.clone());
4160 }
4161 }
4162
4163 self.get_memory_background_model()
4164 .or_else(|| self.get_model())
4165 }
4166
4167 pub fn get_memory_background_model(&self) -> Option<String> {
4179 if self.features.provider_model_ref {
4180 if let Some(model_ref) = self
4181 .defaults
4182 .as_ref()
4183 .and_then(|d| d.memory_background.as_ref())
4184 {
4185 return Some(model_ref.model.clone());
4186 }
4187 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4188 return Some(model_ref.model.clone());
4189 }
4190 }
4191 let configured = self
4192 .memory
4193 .as_ref()
4194 .and_then(|memory| memory.background_model.as_ref())
4195 .map(|value| value.trim())
4196 .filter(|value| !value.is_empty())
4197 .map(ToString::to_string);
4198 configured.or_else(|| {
4199 let provider = self.effective_default_provider();
4200 if let Some(instance) = self.provider_instances.get(provider) {
4201 return instance.fast_model.clone();
4202 }
4203 match provider {
4204 "openai" => self
4205 .providers
4206 .openai
4207 .as_ref()
4208 .and_then(|c| c.fast_model.clone()),
4209 "anthropic" => self
4210 .providers
4211 .anthropic
4212 .as_ref()
4213 .and_then(|c| c.fast_model.clone()),
4214 "gemini" => self
4215 .providers
4216 .gemini
4217 .as_ref()
4218 .and_then(|c| c.fast_model.clone()),
4219 "copilot" => self
4220 .providers
4221 .copilot
4222 .as_ref()
4223 .and_then(|c| c.fast_model.clone()),
4224 _ => None,
4225 }
4226 })
4227 }
4228
4229 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4237 let raw = self
4238 .default_work_area
4239 .as_ref()
4240 .and_then(|config| config.path.as_ref())
4241 .map(|value| value.trim())
4242 .filter(|value| !value.is_empty())?;
4243
4244 let candidate = expand_user_path(raw);
4245 if candidate.is_absolute() {
4246 let canonical = std::fs::canonicalize(&candidate).ok();
4247 return canonical
4248 .as_ref()
4249 .filter(|path| path.is_dir())
4250 .map(|_| candidate.clone())
4251 .or_else(|| candidate.is_dir().then_some(candidate));
4252 }
4253
4254 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4255 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4256 canonical
4257 .as_ref()
4258 .filter(|path| path.is_dir())
4259 .map(|_| from_bamboo_dir.clone())
4260 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4261 .or_else(|| candidate.is_dir().then_some(candidate))
4262 }
4263
4264 pub fn get_vision_model(&self) -> Option<String> {
4269 let provider = self.effective_default_provider();
4270 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4271 instance.vision_model.clone()
4272 } else {
4273 match provider {
4274 "openai" => self
4275 .providers
4276 .openai
4277 .as_ref()
4278 .and_then(|c| c.vision_model.clone()),
4279 "anthropic" => self
4280 .providers
4281 .anthropic
4282 .as_ref()
4283 .and_then(|c| c.vision_model.clone()),
4284 "gemini" => self
4285 .providers
4286 .gemini
4287 .as_ref()
4288 .and_then(|c| c.vision_model.clone()),
4289 "copilot" => self
4290 .providers
4291 .copilot
4292 .as_ref()
4293 .and_then(|c| c.vision_model.clone()),
4294 _ => None,
4295 }
4296 };
4297 vision.or_else(|| self.get_model())
4298 }
4299
4300 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4302 self.reasoning_effort_for_key(self.effective_default_provider())
4303 }
4304
4305 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4315 let trimmed = key.trim();
4316 if trimmed.is_empty() {
4317 return None;
4318 }
4319
4320 if let Some(instance) = self.provider_instances.get(trimmed) {
4322 return instance.reasoning_effort;
4323 }
4324
4325 match trimmed {
4327 "openai" => self
4328 .providers
4329 .openai
4330 .as_ref()
4331 .and_then(|c| c.reasoning_effort),
4332 "anthropic" => self
4333 .providers
4334 .anthropic
4335 .as_ref()
4336 .and_then(|c| c.reasoning_effort),
4337 "gemini" => self
4338 .providers
4339 .gemini
4340 .as_ref()
4341 .and_then(|c| c.reasoning_effort),
4342 "copilot" => self
4343 .providers
4344 .copilot
4345 .as_ref()
4346 .and_then(|c| c.reasoning_effort),
4347 "bodhi" => self
4348 .providers
4349 .bodhi
4350 .as_ref()
4351 .and_then(|c| c.reasoning_effort),
4352 _ => None,
4353 }
4354 }
4355
4356 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4363 self.tools
4364 .disabled
4365 .iter()
4366 .map(|name| name.trim())
4367 .filter(|name| !name.is_empty())
4368 .map(str::to_string)
4369 .collect()
4370 }
4371
4372 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4377 self.disabled_tool_references()
4378 .into_iter()
4379 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4380 .collect()
4381 }
4382
4383 pub fn normalize_tool_settings(&mut self) {
4385 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4386 }
4387
4388 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4390 self.skills
4391 .disabled
4392 .iter()
4393 .map(|id| id.trim())
4394 .filter(|id| !id.is_empty())
4395 .map(|id| id.to_string())
4396 .collect()
4397 }
4398
4399 pub fn normalize_skill_settings(&mut self) {
4401 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4402 }
4403
4404 pub fn normalize_plugin_trust_settings(&mut self) {
4412 self.plugin_trust.trusted_hosts = self
4413 .plugin_trust
4414 .trusted_hosts
4415 .iter()
4416 .map(|entry| entry.trim().to_ascii_lowercase())
4417 .filter(|entry| !entry.is_empty())
4418 .collect();
4419 }
4420
4421 pub fn effective_default_provider(&self) -> &str {
4426 self.default_provider_instance
4427 .as_deref()
4428 .unwrap_or(&self.provider)
4429 }
4430
4431 pub fn has_provider_instances(&self) -> bool {
4433 !self.provider_instances.is_empty()
4434 }
4435
4436 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4438 self.env_vars
4439 .iter()
4440 .filter(|e| !e.value.trim().is_empty())
4441 .map(|e| (e.name.clone(), e.value.clone()))
4442 .collect()
4443 }
4444
4445 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4446 self.env_vars
4447 .iter()
4448 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4449 .map(|entry| PromptSafeEnvVarEntry {
4450 name: entry.name.clone(),
4451 secret: entry.secret,
4452 description: entry
4453 .description
4454 .as_ref()
4455 .map(|value| value.trim().to_string())
4456 .filter(|value| !value.is_empty()),
4457 })
4458 .collect()
4459 }
4460
4461 pub fn publish_env_vars(&self) {
4463 let map = self.env_vars_as_map();
4464
4465 #[cfg(any(test, feature = "test-utils"))]
4466 if crate::test_support::env_vars_cache_override_is_active() {
4467 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4468 return;
4469 }
4470
4471 let mut env_guard = env_vars_cache().write().recover_poison();
4472 *env_guard = map;
4473
4474 let prompt_safe = self.prompt_safe_env_vars();
4475 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4476 *prompt_guard = prompt_safe;
4477 }
4478
4479 pub fn current_env_vars() -> HashMap<String, String> {
4481 #[cfg(any(test, feature = "test-utils"))]
4482 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4483 return env_vars;
4484 }
4485
4486 env_vars_cache().read().recover_poison().clone()
4487 }
4488
4489 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4491 #[cfg(any(test, feature = "test-utils"))]
4492 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4493 return env_vars;
4494 }
4495
4496 prompt_safe_env_vars_cache().read().recover_poison().clone()
4497 }
4498
4499 fn create_default() -> Self {
4501 Self::from_parts(
4502 ConfigValues {
4503 http_proxy: String::new(),
4504 https_proxy: String::new(),
4505 proxy_auth: None,
4506 proxy_auth_encrypted: None,
4507 proxy_auth_credential_ref: None,
4508 headless_auth: false,
4509 run_budget: RunBudgetConfig::default(),
4510 stream_timeout: StreamTimeoutConfig::default(),
4511 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4512 provider: default_provider(),
4513 provider_instances: HashMap::new(),
4514 default_provider_instance: None,
4515 server: ServerConfig::default(),
4516 keyword_masking: KeywordMaskingConfig::default(),
4517 anthropic_model_mapping: AnthropicModelMapping::default(),
4518 gemini_model_mapping: GeminiModelMapping::default(),
4519 hooks: HooksConfig::default(),
4520 lifecycle_hooks: LifecycleHooksConfig::default(),
4521 tools: ToolsConfig::default(),
4522 skills: SkillsConfig::default(),
4523 env_vars: Vec::new(),
4524 default_work_area: None,
4525 access_control: None,
4526 features: FeatureFlags::default(),
4527 defaults: None,
4528 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4529 notifications: NotificationsConfig::default(),
4530 connect: ConnectConfig::default(),
4531 plugin_trust: PluginTrustConfig::default(),
4532 extra: BTreeMap::new(),
4533 },
4534 None,
4535 SubagentsConfig::default(),
4536 ProviderConfigs::default(),
4537 )
4538 }
4539
4540 pub fn server_addr(&self) -> String {
4542 format!("{}:{}", self.server.bind, self.server.port)
4543 }
4544
4545 pub fn save(&self) -> Result<()> {
4547 self.save_to_dir(default_data_dir())
4548 }
4549
4550 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4552 self.memory.save_sync(data_dir)
4553 }
4554
4555 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4557 self.subagents.save_sync(data_dir)
4558 }
4559
4560 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4563 let mut config = self.clone();
4564 config.clear_legacy_provider_aliases_for_instance_mode();
4565 config.refresh_provider_api_keys_encrypted()?;
4566 config.providers.save_sync(data_dir)
4567 }
4568
4569 pub(crate) fn prepare_provider_transaction_documents(
4573 &self,
4574 provider_document: &[u8],
4575 ) -> Result<(Vec<u8>, Vec<u8>)> {
4576 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4577 anyhow::bail!(
4578 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4579 status.source
4580 );
4581 }
4582
4583 let mut to_save = self.clone();
4584 to_save.clear_legacy_provider_aliases_for_instance_mode();
4585 to_save.extra.remove("data_dir");
4586 to_save.extra.remove("model");
4587 to_save.refresh_encrypted_secrets()?;
4588 to_save.ensure_provider_instance_credentials_isolated()?;
4589 to_save.sanitize_mcp_credential_refs_for_disk();
4590 to_save.sanitize_env_vars_for_disk();
4591 to_save.sanitize_notifications_for_disk();
4592 to_save.sanitize_cluster_fabric_for_disk();
4593 to_save.assign_connect_platform_ids();
4594 to_save.normalize_tool_settings();
4595 to_save.normalize_skill_settings();
4596
4597 let mut root = durable_root_value(to_save.values.clone())
4598 .context("Failed to serialize root config DTO to JSON")?;
4599 if let Some(object) = root.as_object_mut() {
4600 object.remove("connect");
4601 }
4602 let root = serde_json::to_vec_pretty(&root)?;
4603
4604 let mut providers = to_save.providers.0.clone();
4605 macro_rules! sanitize_provider {
4606 ($field:ident) => {
4607 if let Some(provider) = providers.$field.as_mut() {
4608 if !provider.api_key.trim().is_empty()
4609 && !provider.api_key_from_env
4610 && provider.credential_ref.is_none()
4611 {
4612 anyhow::bail!(
4613 "provider secret requires credential transaction before persistence"
4614 );
4615 }
4616 provider.api_key_encrypted = None;
4617 }
4618 };
4619 }
4620 sanitize_provider!(openai);
4621 sanitize_provider!(anthropic);
4622 sanitize_provider!(gemini);
4623 if let Some(provider) = providers.bodhi.as_mut() {
4624 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4625 anyhow::bail!("provider secret requires credential transaction before persistence");
4626 }
4627 provider.api_key_encrypted = None;
4628 }
4629
4630 let existing_provider_value = if provider_document.is_empty() {
4631 None
4632 } else {
4633 Some(
4634 serde_json::from_slice::<Value>(provider_document)
4635 .context("provider metadata document is invalid")?,
4636 )
4637 };
4638 let provider_value = match existing_provider_value {
4639 Some(Value::Object(mut envelope))
4640 if envelope.contains_key("schema_version")
4641 || envelope.contains_key("revision")
4642 || envelope.contains_key("data") =>
4643 {
4644 let revision = envelope
4645 .get("revision")
4646 .and_then(Value::as_u64)
4647 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4648 let schema_version = envelope
4649 .get("schema_version")
4650 .and_then(Value::as_u64)
4651 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4652 if schema_version != 1 || !envelope.contains_key("data") {
4653 anyhow::bail!("provider revision envelope is unsupported");
4654 }
4655 let revision = revision
4656 .checked_add(1)
4657 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4658 envelope.insert("revision".into(), Value::from(revision));
4659 envelope.insert("data".into(), serde_json::to_value(providers)?);
4660 Value::Object(envelope)
4661 }
4662 Some(_) | None => serde_json::to_value(providers)?,
4663 };
4664 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4665 }
4666
4667 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4671 self.recovery_status.as_ref()
4672 }
4673
4674 pub fn confirm_recovery(&mut self) {
4680 if let Some(status) = self.recovery_status.as_mut() {
4681 status.confirmed = true;
4682 }
4683 }
4684
4685 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4693 if self.recovery_status.is_none() {
4694 anyhow::bail!("No pending config-corruption recovery to confirm");
4695 }
4696 self.confirm_recovery();
4697 self.save_to_dir(data_dir)?;
4698 self.recovery_status = None;
4699 Ok(())
4700 }
4701
4702 pub fn assign_connect_platform_ids(&mut self) {
4718 for platform in &mut self.connect.platforms {
4719 if platform.id.is_none() {
4720 platform.id = Some(uuid::Uuid::new_v4().to_string());
4721 }
4722 }
4723 }
4724
4725 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
4737 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4738 anyhow::bail!(
4739 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
4740 has not been confirmed; the corrupt original is preserved at {:?}. Call \
4741 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
4742 status.source,
4743 status.quarantine_path,
4744 );
4745 }
4746 if self.proxy_auth_credential_ref.is_none()
4747 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
4748 {
4749 anyhow::bail!(
4750 "proxy auth requires the isolated credential transaction before persistence"
4751 );
4752 }
4753 if self.env_vars.iter().any(|entry| {
4754 entry.secret
4755 && entry.credential_ref.is_none()
4756 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
4757 }) {
4758 anyhow::bail!(
4759 "secret env vars require the isolated credential transaction before persistence"
4760 );
4761 }
4762 if [
4763 (
4764 &self.notifications.ntfy.token,
4765 &self.notifications.ntfy.token_encrypted,
4766 &self.notifications.ntfy.credential_ref,
4767 ),
4768 (
4769 &self.notifications.bark.device_key,
4770 &self.notifications.bark.device_key_encrypted,
4771 &self.notifications.bark.credential_ref,
4772 ),
4773 ]
4774 .iter()
4775 .any(|(plaintext, ciphertext, reference)| {
4776 reference.is_none()
4777 && (plaintext
4778 .as_deref()
4779 .is_some_and(|value| !value.trim().is_empty())
4780 || ciphertext.is_some())
4781 }) {
4782 anyhow::bail!(
4783 "notification secrets require the isolated credential transaction before persistence"
4784 );
4785 }
4786
4787 if crate::modular_authority_boundary_present(&data_dir)
4788 .context("Failed to inspect modular configuration authority boundary")?
4789 {
4790 crate::ConfigFacade::open_or_migrate(&data_dir)
4791 .context("Failed to recover modular configuration before persistence")?;
4792 crate::persist_facade_effective_config(&data_dir, self)
4793 .context("Failed to persist modular configuration sections")?;
4794 return Ok(());
4795 }
4796
4797 if crate::section_layout_is_active(&data_dir)
4798 .context("Failed to inspect modular configuration layout")?
4799 {
4800 crate::persist_facade_effective_config(&data_dir, self)
4801 .context("Failed to persist modular configuration sections")?;
4802 return Ok(());
4803 }
4804
4805 let path = data_dir.join("config.json");
4806
4807 if let Some(parent) = path.parent() {
4808 std::fs::create_dir_all(parent)
4809 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
4810 }
4811
4812 let mut to_save = self.clone();
4813 to_save.clear_legacy_provider_aliases_for_instance_mode();
4814 to_save.extra.remove("data_dir");
4816 to_save.extra.remove("model");
4818 to_save.refresh_encrypted_secrets()?;
4821 to_save.ensure_provider_instance_credentials_isolated()?;
4822 to_save.sanitize_mcp_credential_refs_for_disk();
4823 to_save.sanitize_env_vars_for_disk();
4824 to_save.sanitize_notifications_for_disk();
4825 to_save.sanitize_cluster_fabric_for_disk();
4826 to_save.assign_connect_platform_ids();
4827 to_save.normalize_tool_settings();
4828 to_save.normalize_skill_settings();
4829
4830 let mut config_value = durable_root_value(to_save.values.clone())
4840 .context("Failed to serialize root config DTO to JSON")?;
4841 if let Some(obj) = config_value.as_object_mut() {
4842 obj.remove("connect");
4843 }
4844 let content = serde_json::to_string_pretty(&config_value)
4845 .context("Failed to serialize config to JSON")?;
4846
4847 to_save.memory.save_sync(&data_dir)?;
4854 to_save.subagents.save_sync(&data_dir)?;
4855 to_save.providers.save_sync(&data_dir)?;
4856
4857 if path.exists()
4864 && std::fs::read_to_string(&path)
4865 .ok()
4866 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
4867 {
4868 rotate_backups(&path, BAK_GENERATIONS);
4872 let backup = backup_path_for(&path, 0);
4873 let backup_result = std::fs::read(&path).and_then(|bytes| {
4874 let mut value: Value = serde_json::from_slice(&bytes)
4875 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
4876 if sanitize_ref_backed_mcp_json(&mut value) {
4877 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
4878 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
4879 })?;
4880 write_atomic(&backup, &sanitized)
4881 } else {
4882 std::fs::copy(&path, &backup).map(|_| ())
4883 }
4884 });
4885 if let Err(e) = backup_result {
4886 tracing::warn!("Failed to back up config.json before save: {}", e);
4887 }
4888 scrub_ref_backed_mcp_from_config_backups(&path);
4889 }
4890
4891 write_atomic(&path, content.as_bytes())
4892 .with_context(|| format!("Failed to write config file: {:?}", path))?;
4893
4894 save_connect_config(&to_save.connect, &data_dir)?;
4895
4896 Ok(())
4897 }
4898}
4899
4900fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
4905 let Some(object) = root.as_object_mut() else {
4906 return false;
4907 };
4908 let Some(mcp) = (if object.contains_key("mcpServers") {
4909 object.get_mut("mcpServers")
4910 } else {
4911 object.get_mut("mcp")
4912 }) else {
4913 return false;
4914 };
4915 let mut changed = false;
4916 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
4917 for server in servers {
4918 if let Some(object) = server.as_object_mut() {
4919 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
4920 {
4921 changed |= sanitize_ref_backed_mcp_transport(transport);
4922 }
4923 }
4924 }
4925 } else if let Some(servers) = mcp.as_object_mut() {
4926 for server in servers.values_mut() {
4927 if let Some(object) = server.as_object_mut() {
4928 changed |= sanitize_ref_backed_mcp_transport(object);
4929 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
4930 {
4931 changed |= sanitize_ref_backed_mcp_transport(transport);
4932 }
4933 }
4934 }
4935 }
4936 changed
4937}
4938
4939fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
4940 let mut changed = false;
4941 let env_names = object
4942 .get("env_credential_refs")
4943 .and_then(Value::as_object)
4944 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
4945 .unwrap_or_default();
4946 for name in env_names {
4947 for field in ["env", "env_encrypted"] {
4948 if object
4949 .get_mut(field)
4950 .and_then(Value::as_object_mut)
4951 .and_then(|values| values.remove(&name))
4952 .is_some()
4953 {
4954 changed = true;
4955 }
4956 }
4957 }
4958 let header_names = object
4959 .get("header_credential_refs")
4960 .and_then(Value::as_object)
4961 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
4962 .unwrap_or_default();
4963 for name in header_names {
4964 for field in ["headers", "headers_encrypted"] {
4965 if object
4966 .get_mut(field)
4967 .and_then(Value::as_object_mut)
4968 .and_then(|values| values.remove(&name))
4969 .is_some()
4970 {
4971 changed = true;
4972 }
4973 }
4974 }
4975 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
4976 for header in headers {
4977 let Some(header) = header.as_object_mut() else {
4978 continue;
4979 };
4980 if header.get("credential_ref").is_some_and(Value::is_string) {
4981 changed |= header.remove("value").is_some();
4982 changed |= header.remove("value_encrypted").is_some();
4983 }
4984 }
4985 }
4986 changed
4987}
4988
4989fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
4990 for generation in 0..BAK_GENERATIONS {
4991 let backup = backup_path_for(path, generation);
4992 let Ok(bytes) = std::fs::read(&backup) else {
4993 continue;
4994 };
4995 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
4996 continue;
4997 };
4998 if sanitize_ref_backed_mcp_json(&mut value) {
4999 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5000 if let Err(error) = write_atomic(&backup, &sanitized) {
5001 tracing::warn!(
5002 "Failed to scrub MCP credentials from {:?}: {}",
5003 backup,
5004 error
5005 );
5006 }
5007 }
5008 }
5009 }
5010}
5011
5012fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5022 let path = data_dir.join("connect.json");
5023 if connect_config_is_empty(connect) && !path.exists() {
5024 return Ok(());
5025 }
5026
5027 if path.exists() {
5028 let backup = path.with_extension("json.bak");
5029 if let Err(e) = std::fs::copy(&path, &backup) {
5030 tracing::warn!("Failed to back up connect.json before save: {}", e);
5031 }
5032 }
5033
5034 let content = serde_json::to_string_pretty(connect)
5035 .context("Failed to serialize connect config to JSON")?;
5036 write_atomic(&path, content.as_bytes())
5037 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5038 Ok(())
5039}
5040
5041fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5059 let config_path = data_dir.join("config.json");
5060 let content = match std::fs::read_to_string(&config_path) {
5061 Ok(content) => content,
5062 Err(e) => {
5063 tracing::error!(
5064 "Failed to read config.json to strip legacy `connect` key: {}",
5065 e
5066 );
5067 return;
5068 }
5069 };
5070 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5071 Ok(value) => value,
5072 Err(e) => {
5073 tracing::error!(
5074 "Failed to parse config.json to strip legacy `connect` key: {}",
5075 e
5076 );
5077 return;
5078 }
5079 };
5080 let Some(obj) = value.as_object_mut() else {
5081 return;
5082 };
5083 if obj.remove("connect").is_none() {
5084 return;
5087 }
5088 let rewritten = match serde_json::to_string_pretty(&value) {
5089 Ok(rewritten) => rewritten,
5090 Err(e) => {
5091 tracing::error!(
5092 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5093 e
5094 );
5095 return;
5096 }
5097 };
5098 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5099 tracing::error!(
5100 "Failed to write config.json after stripping legacy `connect` key: {}",
5101 e
5102 );
5103 }
5104}
5105
5106fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5143 let config_path = data_dir.join("config.json");
5144 for gen in 0..BAK_GENERATIONS {
5145 let backup = backup_path_for(&config_path, gen);
5146 let content = match std::fs::read_to_string(&backup) {
5147 Ok(content) => content,
5148 Err(e) => {
5149 if e.kind() != std::io::ErrorKind::NotFound {
5150 tracing::warn!(
5151 "Failed to read {:?} while scanning for legacy connect data ({}); \
5152 leaving it untouched",
5153 backup,
5154 e
5155 );
5156 }
5157 continue;
5158 }
5159 };
5160 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5161 Ok(value) => value,
5162 Err(e) => {
5163 tracing::warn!(
5164 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5165 ({}); left untouched (never deleted)",
5166 backup,
5167 e
5168 );
5169 continue;
5170 }
5171 };
5172 let Some(obj) = value.as_object_mut() else {
5173 continue;
5176 };
5177 if obj.remove("connect").is_none() {
5178 continue;
5181 }
5182 let rewritten = match serde_json::to_string_pretty(&value) {
5183 Ok(rewritten) => rewritten,
5184 Err(e) => {
5185 tracing::error!(
5186 "Failed to serialize {:?} after stripping legacy connect data: {}",
5187 backup,
5188 e
5189 );
5190 continue;
5191 }
5192 };
5193 match write_atomic(&backup, rewritten.as_bytes()) {
5194 Ok(()) => tracing::info!(
5195 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5196 backup
5197 ),
5198 Err(e) => tracing::error!(
5199 "Failed to write {:?} after stripping legacy connect data: {}",
5200 backup,
5201 e
5202 ),
5203 }
5204 }
5205}
5206
5207fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5222 let backup = connect_path.with_extension("json.bak");
5223 match std::fs::rename(connect_path, &backup) {
5224 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5225 Err(e) => {
5226 tracing::warn!(
5227 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5228 backup,
5229 e
5230 );
5231 if let Err(e) = std::fs::copy(connect_path, &backup) {
5232 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5233 return;
5234 }
5235 if let Err(e) = std::fs::remove_file(connect_path) {
5236 tracing::error!(
5237 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5238 original {:?}: {}",
5239 backup,
5240 connect_path,
5241 e
5242 );
5243 }
5244 }
5245 }
5246}
5247
5248const QUARANTINE_KEEP: usize = 5;
5251
5252fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5261 let nanos = std::time::SystemTime::now()
5262 .duration_since(std::time::UNIX_EPOCH)
5263 .map(|d| d.as_nanos())
5264 .unwrap_or(0);
5265 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5269 let mut dedup = 1u32;
5270 while quarantine.exists() {
5271 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5272 dedup += 1;
5273 }
5274 let result = match std::fs::copy(config_path, &quarantine) {
5275 Ok(_) => {
5276 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5277 Some(quarantine)
5278 }
5279 Err(e) => {
5280 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5281 None
5282 }
5283 };
5284 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5285 result
5286}
5287
5288fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5291 let Some(dir) = config_path.parent() else {
5292 return;
5293 };
5294 let prefix = "config.json.corrupted.";
5295 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5296 Ok(entries) => entries
5297 .filter_map(|e| e.ok())
5298 .map(|e| e.path())
5299 .filter(|p| {
5300 p.file_name()
5301 .and_then(|n| n.to_str())
5302 .is_some_and(|n| n.starts_with(prefix))
5303 })
5304 .collect(),
5305 Err(_) => return,
5306 };
5307 if quarantines.len() <= keep {
5308 return;
5309 }
5310 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5312 let remove = quarantines.len() - keep;
5313 for stale in quarantines.into_iter().take(remove) {
5314 if let Err(e) = std::fs::remove_file(&stale) {
5315 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5316 }
5317 }
5318}
5319
5320const BAK_GENERATIONS: usize = 3;
5323
5324fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5326 if gen == 0 {
5327 config_path.with_extension("json.bak")
5328 } else {
5329 config_path.with_extension(format!("json.bak.{gen}"))
5330 }
5331}
5332
5333fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5339 for gen in (1..generations).rev() {
5340 let from = backup_path_for(config_path, gen - 1);
5341 let to = backup_path_for(config_path, gen);
5342 if from.exists() {
5343 if let Err(e) = std::fs::rename(&from, &to) {
5344 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5345 }
5346 }
5347 }
5348}
5349
5350pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5351 crate::config_store::AtomicFileStore::new(path)
5352 .write_bytes_without_backup(content)
5353 .map_err(|error| match error {
5354 crate::config_store::ConfigStoreError::Io(error) => error,
5355 other => std::io::Error::other(other),
5356 })
5357}
5358
5359#[cfg(test)]
5360mod tests {
5361 use super::*;
5362 use std::ffi::OsString;
5363 use std::path::PathBuf;
5364 use std::sync::Mutex;
5365 use std::time::{SystemTime, UNIX_EPOCH};
5366
5367 #[test]
5368 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5369 let input = serde_json::json!({
5370 "disabled": ["bash"],
5371 "plugin_runtime": {
5372 "timeout_ms": 5_000,
5373 "sandbox": true
5374 },
5375 "future_flag": "enabled"
5376 });
5377
5378 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5379 assert_eq!(config.disabled, vec!["bash"]);
5380 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5381 assert_eq!(config.extra["future_flag"], "enabled");
5382 assert_eq!(serde_json::to_value(config).unwrap(), input);
5383 }
5384
5385 #[test]
5386 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5387 let input = serde_json::json!({
5388 "tool_extension": {
5389 "mode": "strict",
5390 "options": ["one", "two"]
5391 }
5392 });
5393 let config: Config = serde_json::from_value(serde_json::json!({
5394 "tools": input.clone()
5395 }))
5396 .unwrap();
5397
5398 assert!(config.tools.disabled.is_empty());
5399 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5400 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5401
5402 let temp_home = TempHome::new();
5403 config.save_to_dir(temp_home.path.clone()).unwrap();
5404 let persisted: Value =
5405 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5406 .unwrap();
5407 assert_eq!(persisted["tools"], input);
5408 }
5409
5410 #[test]
5411 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5412 let input = serde_json::json!({
5413 "external_catalog": {
5414 "path": "/opt/bamboo/skills",
5415 "refresh": false
5416 },
5417 "schema_version": 2
5418 });
5419
5420 let config: Config = serde_json::from_value(serde_json::json!({
5421 "skills": input.clone()
5422 }))
5423 .unwrap();
5424 assert!(config.skills.disabled.is_empty());
5425 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5426 assert_eq!(config.skills.extra["schema_version"], 2);
5427 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5428
5429 let temp_home = TempHome::new();
5430 config.save_to_dir(temp_home.path.clone()).unwrap();
5431 let persisted: Value =
5432 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5433 .unwrap();
5434 assert_eq!(persisted["skills"], input);
5435 }
5436
5437 #[test]
5438 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5439 let config: Config = serde_json::from_value(serde_json::json!({
5440 "hooks": {
5441 "image_fallback": {"enabled": true, "mode": "placeholder"}
5442 },
5443 "lifecycle_hooks": {
5444 "enabled": true,
5445 "PreToolUse": [{
5446 "matcher": "bash|write_file",
5447 "hooks": [{"type": "command", "command": "guard.sh"}]
5448 }],
5449 "SessionStart": [{
5450 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5451 }]
5452 }
5453 }))
5454 .expect("lifecycle hook config should deserialize");
5455
5456 assert!(config.lifecycle_hooks.enabled);
5457 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5458 assert!(
5459 config.lifecycle_hooks.pre_tool_use[0].enabled,
5460 "legacy groups without an enabled flag remain active"
5461 );
5462 assert_eq!(
5463 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5464 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5465 );
5466 assert_eq!(
5467 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5468 25
5469 );
5470
5471 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5472 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5473 assert_eq!(
5474 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5475 "bash|write_file"
5476 );
5477 assert_eq!(
5478 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5479 "command"
5480 );
5481 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5482 .get("enabled")
5483 .is_none());
5484 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5485 .get("timeout_ms")
5486 .is_none());
5487 assert!(json.get("hooks").is_some());
5488 }
5489
5490 #[test]
5491 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5492 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5493 "type": "script",
5494 "path": ".bamboo/hooks/check.js"
5495 }))
5496 .expect("script lifecycle hook should deserialize");
5497
5498 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5499 assert!(matches!(
5500 handler,
5501 LifecycleHookHandler::Script {
5502 runner: LifecycleScriptRunner::Auto,
5503 ..
5504 }
5505 ));
5506 let json = serde_json::to_value(handler).expect("script hook should serialize");
5507 assert_eq!(json["type"], "script");
5508 assert!(json.get("timeout_ms").is_none());
5509 assert!(json.get("runner").is_none());
5510 }
5511
5512 #[test]
5513 fn script_runner_support_is_extension_aware() {
5514 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5515 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5516 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5517 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5518 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5519 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5520 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5521 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5522 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5523 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5524 }
5525
5526 #[test]
5527 fn script_runner_names_round_trip_through_config_json() {
5528 for (runner, name) in [
5529 (LifecycleScriptRunner::Auto, "auto"),
5530 (LifecycleScriptRunner::Node, "node"),
5531 (LifecycleScriptRunner::Bun, "bun"),
5532 (LifecycleScriptRunner::Python, "python"),
5533 (LifecycleScriptRunner::Bash, "bash"),
5534 (LifecycleScriptRunner::PowerShell, "powershell"),
5535 (LifecycleScriptRunner::Cmd, "cmd"),
5536 ] {
5537 let json = serde_json::to_value(runner).unwrap();
5538 assert_eq!(json, name);
5539 assert_eq!(
5540 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5541 runner
5542 );
5543 }
5544 }
5545
5546 #[test]
5547 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5548 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5549 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5550 let json = serde_json::to_value(&config).unwrap();
5551 assert!(json.get("lifecycle_hooks").is_none());
5552 }
5553
5554 #[test]
5555 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5556 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5557 .expect("legacy config without stream_timeout should load");
5558 let values = ConfigValues::from(root);
5559
5560 assert_eq!(
5561 values.stream_timeout,
5562 StreamTimeoutConfig {
5563 transport_idle_timeout_secs: 120,
5564 first_semantic_timeout_secs: 600,
5565 semantic_idle_timeout_secs: 600,
5566 }
5567 );
5568 values
5569 .stream_timeout
5570 .validate()
5571 .expect("defaults are valid");
5572 }
5573
5574 #[test]
5575 fn stream_timeout_round_trips_through_persistence_dto() {
5576 let values = ConfigValues {
5577 stream_timeout: StreamTimeoutConfig {
5578 transport_idle_timeout_secs: 45,
5579 first_semantic_timeout_secs: 900,
5580 semantic_idle_timeout_secs: 300,
5581 },
5582 ..ConfigValues::default()
5583 };
5584
5585 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5586 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5587 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5588 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5589
5590 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5591 assert_eq!(
5592 ConfigValues::from(root).stream_timeout,
5593 StreamTimeoutConfig {
5594 transport_idle_timeout_secs: 45,
5595 first_semantic_timeout_secs: 900,
5596 semantic_idle_timeout_secs: 300,
5597 }
5598 );
5599 }
5600
5601 #[test]
5602 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
5603 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5604 "provider_type": "openai",
5605 "enabled": true
5606 }))
5607 .unwrap();
5608 let mut config = Config::default();
5609 config.values.provider = "gemini".to_string();
5610 config
5611 .provider_instances
5612 .insert("work".to_string(), instance);
5613 config.default_provider_instance = Some("work".to_string());
5614 config.providers_mut().openai = Some(OpenAIConfig::default());
5615
5616 let json = serde_json::to_value(&config).unwrap();
5617 assert_eq!(json["provider"], "gemini");
5618 assert!(json["providers"]["openai"].is_object());
5619 assert_eq!(json["default_provider_instance"], "work");
5620
5621 let round_trip: Config = serde_json::from_value(json).unwrap();
5622 assert_eq!(round_trip.provider, "gemini");
5623 assert_eq!(
5624 round_trip.default_provider_instance.as_deref(),
5625 Some("work")
5626 );
5627 assert!(round_trip.provider_instances.contains_key("work"));
5628 assert!(round_trip.providers().openai.is_some());
5629 }
5630
5631 #[test]
5632 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
5633 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
5634 let dir = tempfile::tempdir().unwrap();
5635 let mut config = Config::default();
5636 config.values.provider = "anthropic".to_string();
5637 config.provider_instances.insert(
5638 "work".to_string(),
5639 serde_json::from_value(serde_json::json!({
5640 "provider_type": "openai",
5641 "model": "gpt-instance",
5642 "enabled": true,
5643 "future_instance_metadata": "preserved"
5644 }))
5645 .unwrap(),
5646 );
5647 config.default_provider_instance = Some("work".to_string());
5648 config.features.provider_model_ref = true;
5649 config.providers_mut().openai = Some(OpenAIConfig::default());
5650 config.providers_mut().anthropic = Some(AnthropicConfig::default());
5651 config
5652 .providers_mut()
5653 .extra
5654 .insert("provider".to_string(), serde_json::json!("anthropic"));
5655 config.providers_mut().extra.insert(
5656 "future_provider".to_string(),
5657 serde_json::json!({"kept": true}),
5658 );
5659
5660 let (root_bytes, provider_bytes) =
5661 config.prepare_provider_transaction_documents(&[]).unwrap();
5662 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5663 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5664 assert!(root.get("provider").is_none());
5665 assert!(root.get("providers").is_none());
5666 assert_eq!(root["default_provider_instance"], "work");
5667 assert_eq!(
5668 root["provider_instances"]["work"]["future_instance_metadata"],
5669 "preserved"
5670 );
5671 assert!(providers.get("provider").is_none());
5672 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
5673 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
5674 }
5675 assert_eq!(providers["future_provider"]["kept"], true);
5676
5677 config.save_to_dir(dir.path().to_path_buf()).unwrap();
5678 let saved_root: Value =
5679 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
5680 .unwrap();
5681 let saved_providers: Value =
5682 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
5683 .unwrap();
5684 assert!(saved_root.get("provider").is_none());
5685 assert!(saved_root.get("providers").is_none());
5686 assert_eq!(saved_root["default_provider_instance"], "work");
5687 assert!(saved_providers.get("openai").is_none());
5688 assert!(saved_providers.get("anthropic").is_none());
5689 assert!(saved_providers.get("provider").is_none());
5690 assert_eq!(saved_providers["future_provider"]["kept"], true);
5691
5692 config.providers_mut().gemini = Some(GeminiConfig::default());
5693 config.save_providers_to_dir(dir.path()).unwrap();
5694 let provider_only: Value =
5695 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
5696 .unwrap();
5697 assert!(provider_only.get("gemini").is_none());
5698 assert!(provider_only.get("provider").is_none());
5699 assert_eq!(provider_only["future_provider"]["kept"], true);
5700 }
5701
5702 #[test]
5703 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
5704 let mut config = Config::default();
5705 config.values.provider = "openai".to_string();
5706 config.providers_mut().openai = Some(OpenAIConfig {
5707 model: Some("gpt-legacy".to_string()),
5708 ..OpenAIConfig::default()
5709 });
5710 config.provider_instances.insert(
5711 "work".to_string(),
5712 serde_json::from_value(serde_json::json!({
5713 "provider_type": "copilot",
5714 "enabled": true
5715 }))
5716 .unwrap(),
5717 );
5718 config.default_provider_instance = Some("openai".to_string());
5719
5720 let (root_bytes, provider_bytes) =
5721 config.prepare_provider_transaction_documents(&[]).unwrap();
5722 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5723 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5724 assert_eq!(root["provider"], "openai");
5725 assert_eq!(root["default_provider_instance"], "openai");
5726 assert!(root["provider_instances"]["work"].is_object());
5727 assert_eq!(providers["openai"]["model"], "gpt-legacy");
5728 }
5729
5730 #[test]
5731 fn persistence_keeps_legacy_provider_without_instance_default() {
5732 let values = ConfigValues {
5733 provider: "gemini".to_string(),
5734 ..ConfigValues::default()
5735 };
5736
5737 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5738 assert_eq!(json["provider"], "gemini");
5739 }
5740
5741 #[test]
5742 fn stream_timeout_rejects_zero_and_unbounded_values() {
5743 for (field, invalid) in [
5744 ("transport_idle_timeout_secs", 0),
5745 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
5746 ("semantic_idle_timeout_secs", 0),
5747 ] {
5748 let mut timeout = serde_json::json!({});
5749 timeout[field] = serde_json::json!(invalid);
5750 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
5751 .expect_err("invalid timeout must be rejected");
5752 assert!(error.to_string().contains("stream timeout must be between"));
5753 }
5754 }
5755
5756 struct EnvVarGuard {
5757 key: &'static str,
5758 previous: Option<OsString>,
5759 }
5760
5761 impl EnvVarGuard {
5762 fn set(key: &'static str, value: &str) -> Self {
5763 let previous = std::env::var_os(key);
5764 std::env::set_var(key, value);
5765 Self { key, previous }
5766 }
5767
5768 fn unset(key: &'static str) -> Self {
5769 let previous = std::env::var_os(key);
5770 std::env::remove_var(key);
5771 Self { key, previous }
5772 }
5773 }
5774
5775 impl Drop for EnvVarGuard {
5776 fn drop(&mut self) {
5777 match &self.previous {
5778 Some(value) => std::env::set_var(self.key, value),
5779 None => std::env::remove_var(self.key),
5780 }
5781 }
5782 }
5783
5784 #[test]
5785 fn run_budget_config_merge_is_tighten_only_per_field() {
5786 let config_default = RunBudgetConfig {
5787 max_total_tokens: Some(100_000),
5788 max_tool_calls: Some(500),
5789 max_subagents: Some(10),
5790 };
5791
5792 assert_eq!(
5794 config_default.merged_with_override(None),
5795 config_default,
5796 "no override falls back to the config default entirely"
5797 );
5798
5799 let tighten_one = RunBudgetConfig {
5802 max_total_tokens: Some(5_000),
5803 max_tool_calls: None,
5804 max_subagents: None,
5805 };
5806 let merged = config_default.merged_with_override(Some(&tighten_one));
5807 assert_eq!(merged.max_total_tokens, Some(5_000));
5808 assert_eq!(merged.max_tool_calls, Some(500));
5809 assert_eq!(merged.max_subagents, Some(10));
5810
5811 let loosen_attempt = RunBudgetConfig {
5814 max_total_tokens: Some(999_999_999),
5815 max_tool_calls: Some(10_000),
5816 max_subagents: Some(1_000),
5817 };
5818 assert_eq!(
5819 config_default.merged_with_override(Some(&loosen_attempt)),
5820 config_default,
5821 "looser per-request values must be clamped to the config ceiling"
5822 );
5823
5824 let empty_override = RunBudgetConfig::default();
5828 assert_eq!(
5829 config_default.merged_with_override(Some(&empty_override)),
5830 config_default,
5831 "an all-absent override body keeps every configured ceiling"
5832 );
5833
5834 let unlimited_default = RunBudgetConfig::default();
5838 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
5839 assert_eq!(merged.max_total_tokens, Some(5_000));
5840 assert_eq!(merged.max_tool_calls, None);
5841 assert_eq!(merged.max_subagents, None);
5842 }
5843
5844 #[test]
5845 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
5846 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
5847 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
5848 assert_eq!(RunBudgetConfig::default().max_subagents, None);
5849
5850 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
5851 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
5852 assert_eq!(cfg.max_total_tokens, Some(250_000));
5853 assert_eq!(
5854 cfg.max_tool_calls, None,
5855 "absent field defaults to unlimited"
5856 );
5857 assert_eq!(cfg.max_subagents, Some(3));
5858
5859 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
5863 assert_eq!(empty, "{}");
5864 }
5865
5866 #[test]
5867 fn subagents_config_without_remote_placements_deserializes_empty() {
5868 let json = r#"{ "max_concurrent": 4 }"#;
5871 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5872 assert_eq!(cfg.max_concurrent, Some(4));
5873 assert!(cfg.remote_placements.is_empty());
5874 let back = serde_json::to_string(&cfg).unwrap();
5876 assert!(
5877 !back.contains("remote_placements"),
5878 "empty vec is skipped: {back}"
5879 );
5880 }
5881
5882 #[test]
5883 fn remote_actor_placement_round_trips() {
5884 let json = r#"{
5885 "remote_placements": [
5886 {
5887 "role": "explorer",
5888 "endpoint": "wss://gpu-host:8443",
5889 "token_env": "WORKER_TOKEN",
5890 "ca_cert_file": "/etc/bamboo/worker.pem"
5891 },
5892 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
5893 ]
5894 }"#;
5895 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5896 assert_eq!(cfg.remote_placements.len(), 2);
5897 let p0 = &cfg.remote_placements[0];
5898 assert_eq!(p0.role, "explorer");
5899 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
5900 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
5901 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
5902 let p1 = &cfg.remote_placements[1];
5904 assert_eq!(p1.role, "writer");
5905 assert!(p1.token_env.is_none());
5906 assert!(p1.ca_cert_file.is_none());
5907
5908 let back = serde_json::to_string(&cfg).unwrap();
5909 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5910 assert_eq!(cfg, reparsed, "round-trip is stable");
5911 assert!(!back.contains("\"token_env\":null"));
5912 assert!(!back.contains("\"ca_cert_file\":null"));
5913 }
5914
5915 #[test]
5916 fn subagents_config_without_schedulable_placements_deserializes_empty() {
5917 let json = r#"{ "max_concurrent": 4 }"#;
5920 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5921 assert!(cfg.schedulable_placements.is_empty());
5922 let back = serde_json::to_string(&cfg).unwrap();
5924 assert!(
5925 !back.contains("schedulable_placements"),
5926 "empty vec is skipped: {back}"
5927 );
5928 }
5929
5930 #[test]
5931 fn schedulable_placement_round_trips() {
5932 let json = r#"{
5933 "schedulable_placements": [
5934 {
5935 "role": "explorer",
5936 "pool": "gpu-pool",
5937 "registry_url": "https://control-plane:9562",
5938 "token_env": "WORKER_TOKEN",
5939 "ca_cert_file": "/etc/bamboo/worker.pem"
5940 },
5941 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
5942 ]
5943 }"#;
5944 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5945 assert_eq!(cfg.schedulable_placements.len(), 2);
5946 let p0 = &cfg.schedulable_placements[0];
5947 assert_eq!(p0.role, "explorer");
5948 assert_eq!(p0.pool, "gpu-pool");
5949 assert_eq!(p0.registry_url, "https://control-plane:9562");
5950 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
5951 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
5952 let p1 = &cfg.schedulable_placements[1];
5954 assert_eq!(p1.role, "writer");
5955 assert_eq!(p1.pool, "cpu-pool");
5956 assert!(p1.token_env.is_none());
5957 assert!(p1.ca_cert_file.is_none());
5958
5959 let back = serde_json::to_string(&cfg).unwrap();
5960 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5961 assert_eq!(cfg, reparsed, "round-trip is stable");
5962 assert!(!back.contains("\"token_env\":null"));
5963 assert!(!back.contains("\"ca_cert_file\":null"));
5964 }
5965
5966 #[test]
5967 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
5968 let json = r#"{ "max_concurrent": 4 }"#;
5972 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5973 assert!(cfg.mcp_role_allowlist.is_empty());
5974 let back = serde_json::to_string(&cfg).unwrap();
5976 assert!(
5977 !back.contains("mcp_role_allowlist"),
5978 "empty vec is skipped: {back}"
5979 );
5980 }
5981
5982 #[test]
5983 fn mcp_role_allowlist_entry_round_trips() {
5984 let json = r#"{
5985 "mcp_role_allowlist": [
5986 { "role": "researcher", "tools": ["fetch_url"] },
5987 { "role": "sandboxed", "tools": [] }
5988 ]
5989 }"#;
5990 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5991 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
5992 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
5993 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
5994 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
5997 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
5998
5999 let back = serde_json::to_string(&cfg).unwrap();
6000 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6001 assert_eq!(cfg, reparsed, "round-trip is stable");
6002 }
6003
6004 #[test]
6005 fn server_config_without_tls_field_deserializes_back_compat() {
6006 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6009 "port": 9562,
6010 "bind": "127.0.0.1"
6011 }))
6012 .expect("legacy server config without tls should deserialize");
6013
6014 assert_eq!(server.tls, None);
6015 assert_eq!(server.port, 9562);
6016 assert_eq!(server.bind, "127.0.0.1");
6017 }
6018
6019 #[test]
6020 fn server_config_omits_tls_when_none() {
6021 let server = ServerConfig::default();
6024 let value = serde_json::to_value(&server).expect("server config should serialize");
6025 let obj = value
6026 .as_object()
6027 .expect("server config serializes to object");
6028 assert!(
6029 !obj.contains_key("tls"),
6030 "tls must be omitted when None, got: {value}"
6031 );
6032 }
6033
6034 #[test]
6035 fn server_config_with_tls_roundtrips() {
6036 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6037 "port": 9562,
6038 "bind": "0.0.0.0",
6039 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6040 }))
6041 .expect("server config with tls should deserialize");
6042
6043 let tls = server.tls.clone().expect("tls should be Some");
6044 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6045 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6046
6047 let value = serde_json::to_value(&server).expect("serialize");
6049 assert!(value.as_object().unwrap().contains_key("tls"));
6050 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6051 assert_eq!(back.tls, server.tls);
6052 }
6053
6054 #[test]
6055 fn access_control_without_devices_field_deserializes_back_compat() {
6056 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6059 "password_enabled": true,
6060 "password_hash": "deadbeef",
6061 "password_salt": "01020304",
6062 }))
6063 .expect("legacy access_control without devices should deserialize");
6064
6065 assert!(access.devices.is_empty());
6066 assert!(access.password_enabled);
6067 }
6068
6069 #[test]
6070 fn access_control_omits_devices_when_empty() {
6071 let access = AccessControlConfig {
6074 password_enabled: true,
6075 repair_required: false,
6076 password_hash: Some("deadbeef".to_string()),
6077 password_salt: Some("01020304".to_string()),
6078 password_credential_ref: None,
6079 password_configured: false,
6080 updated_at: None,
6081 devices: Vec::new(),
6082 };
6083 let value = serde_json::to_value(&access).expect("serialize");
6084 let obj = value.as_object().expect("object");
6085 assert!(
6086 !obj.contains_key("devices"),
6087 "devices must be omitted when empty, got: {value}"
6088 );
6089 }
6090
6091 #[test]
6092 fn access_control_with_devices_roundtrips() {
6093 let device = DeviceCredential {
6094 device_id: "bamboo_0123456789ab".to_string(),
6095 label: "iPhone 15".to_string(),
6096 token_hash: "abcd".to_string(),
6097 token_salt: "ef01".to_string(),
6098 token_credential_ref: None,
6099 token_configured: false,
6100 created_at: "2026-06-23T00:00:00Z".to_string(),
6101 last_used_at: None,
6102 revoked: false,
6103 };
6104 let access = AccessControlConfig {
6105 password_enabled: true,
6106 repair_required: false,
6107 password_hash: Some("deadbeef".to_string()),
6108 password_salt: Some("01020304".to_string()),
6109 password_credential_ref: None,
6110 password_configured: false,
6111 updated_at: None,
6112 devices: vec![device.clone()],
6113 };
6114 let value = serde_json::to_value(&access).expect("serialize");
6115 assert!(value.as_object().unwrap().contains_key("devices"));
6116 assert!(value["devices"][0].get("token_hash").is_none());
6117 assert!(value["devices"][0].get("token_salt").is_none());
6118 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6119 assert_eq!(back.devices[0].device_id, device.device_id);
6120 assert!(back.devices[0].token_hash.is_empty());
6121 assert!(back.devices[0].token_salt.is_empty());
6122 }
6123
6124 #[test]
6125 fn reasoning_effort_for_key_resolves_instance_id() {
6126 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6130 "provider_type": "copilot",
6131 "reasoning_effort": "high",
6132 }))
6133 .expect("instance config should deserialize");
6134
6135 let mut config = Config::create_default();
6136 config
6137 .provider_instances
6138 .insert("copilot-work".to_string(), instance);
6139
6140 assert_eq!(
6141 config.reasoning_effort_for_key("copilot-work"),
6142 Some(ReasoningEffort::High),
6143 );
6144 }
6145
6146 #[test]
6147 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6148 let mut config = Config::create_default();
6150 config.providers.bodhi = Some(
6151 serde_json::from_value(serde_json::json!({
6152 "reasoning_effort": "xhigh",
6153 }))
6154 .expect("bodhi config should deserialize"),
6155 );
6156
6157 assert_eq!(
6158 config.reasoning_effort_for_key("bodhi"),
6159 Some(ReasoningEffort::Xhigh),
6160 );
6161 }
6162
6163 #[test]
6164 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6165 let mut config = Config::create_default();
6166 config.providers.openai = Some(
6167 serde_json::from_value(serde_json::json!({
6168 "api_key": "sk-test",
6169 "reasoning_effort": "low",
6170 }))
6171 .expect("openai config should deserialize"),
6172 );
6173
6174 assert_eq!(
6175 config.reasoning_effort_for_key("openai"),
6176 Some(ReasoningEffort::Low),
6177 );
6178 }
6179
6180 #[test]
6181 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6182 let config = Config::create_default();
6183 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6184 assert_eq!(config.reasoning_effort_for_key(" "), None);
6185 }
6186
6187 struct TempHome {
6188 path: PathBuf,
6189 }
6190
6191 impl TempHome {
6192 fn new() -> Self {
6193 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6205 std::sync::atomic::AtomicU64::new(0);
6206 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6207 let nanos = SystemTime::now()
6208 .duration_since(UNIX_EPOCH)
6209 .expect("clock should be after unix epoch")
6210 .as_nanos();
6211 let path = std::env::temp_dir().join(format!(
6212 "chat-core-config-test-{}-{}-{}",
6213 std::process::id(),
6214 nanos,
6215 unique
6216 ));
6217 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6218 Self { path }
6219 }
6220
6221 fn set_config_json(&self, content: &str) {
6222 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6225 std::fs::write(self.path.join("config.json"), content)
6226 .expect("failed to write config.json");
6227 }
6228 }
6229
6230 impl Drop for TempHome {
6231 fn drop(&mut self) {
6232 let _ = std::fs::remove_dir_all(&self.path);
6233 }
6234 }
6235
6236 fn env_lock() -> &'static Mutex<()> {
6240 crate::test_support::env_cache_lock()
6241 }
6242
6243 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6245 env_lock().lock().unwrap_or_else(|poisoned| {
6246 poisoned.into_inner()
6248 })
6249 }
6250
6251 #[test]
6252 fn parse_bool_env_true_values() {
6253 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6254 assert!(parse_bool_env(value), "value {value:?} should be true");
6255 }
6256 }
6257
6258 #[test]
6259 fn parse_bool_env_false_values() {
6260 for value in ["0", "false", "no", "off", "", " "] {
6261 assert!(!parse_bool_env(value), "value {value:?} should be false");
6262 }
6263 }
6264
6265 #[test]
6266 fn config_new_ignores_http_proxy_env_vars() {
6267 let _lock = env_lock_acquire();
6268 let temp_home = TempHome::new();
6269 temp_home.set_config_json(
6270 r#"{
6271 "http_proxy": "",
6272 "https_proxy": ""
6273}"#,
6274 );
6275
6276 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6277 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6278
6279 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6280
6281 assert!(
6282 config.http_proxy.is_empty(),
6283 "config should ignore HTTP_PROXY env var"
6284 );
6285 assert!(
6286 config.https_proxy.is_empty(),
6287 "config should ignore HTTPS_PROXY env var"
6288 );
6289 }
6290
6291 #[test]
6292 fn config_new_loads_config_when_proxy_fields_omitted() {
6293 let _lock = env_lock_acquire();
6294 let temp_home = TempHome::new();
6295 temp_home.set_config_json(
6296 r#"{
6297 "provider": "openai",
6298 "providers": {
6299 "openai": {
6300 "api_key": "sk-test",
6301 "model": "gpt-4o"
6302 }
6303 }
6304}"#,
6305 );
6306
6307 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6308 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6309
6310 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6311
6312 assert_eq!(
6313 config
6314 .providers
6315 .openai
6316 .as_ref()
6317 .and_then(|c| c.model.as_deref()),
6318 Some("gpt-4o"),
6319 "config should load provider model from config file even when proxy fields are omitted"
6320 );
6321 assert!(config.http_proxy.is_empty());
6322 assert!(config.https_proxy.is_empty());
6323 }
6324
6325 #[test]
6326 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6327 let _lock = crate::test_support::env_cache_lock_acquire();
6328 let mut config = Config::default();
6329 config.env_vars.extend([
6330 EnvVarEntry {
6331 name: "SECRET_TOKEN".to_string(),
6332 value: "top-secret".to_string(),
6333 secret: true,
6334 value_encrypted: None,
6335 credential_ref: None,
6336 configured: true,
6337 description: Some("Service token".to_string()),
6338 },
6339 EnvVarEntry {
6340 name: "API_BASE".to_string(),
6341 value: "https://internal.example".to_string(),
6342 secret: false,
6343 value_encrypted: None,
6344 credential_ref: None,
6345 configured: true,
6346 description: Some("Internal API base".to_string()),
6347 },
6348 ]);
6349
6350 config.publish_env_vars();
6351
6352 let injected = Config::current_env_vars();
6353 assert_eq!(
6354 injected.get("SECRET_TOKEN").map(String::as_str),
6355 Some("top-secret")
6356 );
6357 assert_eq!(
6358 injected.get("API_BASE").map(String::as_str),
6359 Some("https://internal.example")
6360 );
6361
6362 let prompt_safe = Config::current_prompt_safe_env_vars();
6363 assert_eq!(prompt_safe.len(), 2);
6364 assert!(prompt_safe.iter().any(|entry| {
6365 entry.name == "SECRET_TOKEN"
6366 && entry.secret
6367 && entry.description.as_deref() == Some("Service token")
6368 }));
6369 assert!(prompt_safe.iter().any(|entry| {
6370 entry.name == "API_BASE"
6371 && !entry.secret
6372 && entry.description.as_deref() == Some("Internal API base")
6373 }));
6374 assert!(!prompt_safe
6375 .iter()
6376 .any(|entry| entry.name.contains("top-secret")));
6377 assert!(!prompt_safe.iter().any(|entry| {
6378 entry
6379 .description
6380 .as_deref()
6381 .is_some_and(|value| value.contains("https://internal.example"))
6382 }));
6383 }
6384
6385 #[test]
6386 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6387 let _lock = crate::test_support::env_cache_lock_acquire();
6388
6389 let mut live = Config::default();
6391 live.env_vars.extend([EnvVarEntry {
6392 name: "BAMBOO_CACHE_OWNER_40".to_string(),
6393 value: "live".to_string(),
6394 secret: false,
6395 value_encrypted: None,
6396 credential_ref: None,
6397 configured: true,
6398 description: None,
6399 }]);
6400 live.publish_env_vars();
6401 assert_eq!(
6402 Config::current_env_vars()
6403 .get("BAMBOO_CACHE_OWNER_40")
6404 .map(String::as_str),
6405 Some("live")
6406 );
6407
6408 let temp = TempHome::new();
6410 temp.set_config_json(
6411 &serde_json::json!({
6412 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6413 })
6414 .to_string(),
6415 );
6416
6417 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6420 assert_eq!(
6421 loaded
6422 .env_vars
6423 .iter()
6424 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6425 .map(|e| e.value.as_str()),
6426 Some("stale-disk"),
6427 "the returned Config holds the disk value"
6428 );
6429 assert_eq!(
6430 Config::current_env_vars()
6431 .get("BAMBOO_CACHE_OWNER_40")
6432 .map(String::as_str),
6433 Some("live"),
6434 "but the global cache is UNTOUCHED — no clobber (#40)"
6435 );
6436
6437 let _ = Config::from_data_dir(Some(temp.path.clone()));
6439 assert_eq!(
6440 Config::current_env_vars()
6441 .get("BAMBOO_CACHE_OWNER_40")
6442 .map(String::as_str),
6443 Some("stale-disk"),
6444 "the publishing loader clobbers the cache (contrast)"
6445 );
6446 }
6447
6448 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6449 std::fs::read_dir(dir)
6450 .unwrap()
6451 .filter_map(|e| e.ok())
6452 .any(|e| {
6453 e.file_name()
6454 .to_string_lossy()
6455 .contains("config.json.corrupted.")
6456 })
6457 }
6458
6459 #[test]
6460 fn corrupt_config_recovered_from_backup_and_quarantined() {
6461 let temp = TempHome::new();
6462 std::fs::write(
6464 temp.path.join("config.json.bak"),
6465 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6466 )
6467 .unwrap();
6468 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6470
6471 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6472 assert_eq!(
6473 config.http_proxy, "http://from-backup",
6474 "recovered from config.json.bak instead of losing all config"
6475 );
6476 assert!(
6477 dir_has_quarantine_file(&temp.path),
6478 "corrupt config.json was quarantined (preserved), not discarded"
6479 );
6480 }
6481
6482 #[test]
6483 fn corrupt_config_without_backup_quarantines_then_defaults() {
6484 let temp = TempHome::new();
6485 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6486
6487 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6488 assert!(
6489 config.http_proxy.is_empty(),
6490 "no backup -> falls back to defaults"
6491 );
6492 assert!(
6493 dir_has_quarantine_file(&temp.path),
6494 "corrupt config.json is quarantined even when there's no backup"
6495 );
6496 }
6497
6498 #[test]
6499 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6500 let temp = TempHome::new();
6501 temp.set_config_json(
6506 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6507 );
6508
6509 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6510 assert_eq!(
6511 config.http_proxy, "http://salvaged",
6512 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
6513 );
6514 assert!(
6515 config.env_vars.is_empty(),
6516 "the corrupt field reset to its default instead of failing the whole load"
6517 );
6518 assert!(
6519 dir_has_quarantine_file(&temp.path),
6520 "the corrupt config.json was still quarantined for inspection"
6521 );
6522 }
6523
6524 #[test]
6525 fn salvage_preferred_over_backup_for_most_recent_intent() {
6526 let temp = TempHome::new();
6527 std::fs::write(
6529 temp.path.join("config.json.bak"),
6530 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
6531 )
6532 .unwrap();
6533 temp.set_config_json(
6535 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
6536 );
6537
6538 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6539 assert_eq!(
6540 config.http_proxy, "http://new-salvaged",
6541 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
6542 );
6543 }
6544
6545 #[test]
6546 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
6547 let temp = TempHome::new();
6548 std::fs::write(
6550 temp.path.join("config.json.bak"),
6551 serde_json::json!({
6552 "http_proxy": "http://old-from-backup",
6553 "https_proxy": "https://kept-from-backup",
6554 })
6555 .to_string(),
6556 )
6557 .unwrap();
6558 temp.set_config_json(
6561 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
6562 );
6563
6564 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6565 assert_eq!(
6567 config.http_proxy, "http://newer-edit",
6568 "the corrupt file's newer valid edit is applied"
6569 );
6570 assert_eq!(
6572 config.https_proxy, "https://kept-from-backup",
6573 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
6574 );
6575 }
6576
6577 #[test]
6578 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
6579 let temp = TempHome::new();
6580 std::fs::write(
6581 temp.path.join("config.json.bak"),
6582 serde_json::json!({
6583 "providers": {
6584 "anthropic": { "model": "claude-backup" }
6585 },
6586 "memory": {
6587 "auto_dream_enabled": true
6588 },
6589 "subagents": {
6590 "claude_code_model": "claude-code-backup"
6591 }
6592 })
6593 .to_string(),
6594 )
6595 .unwrap();
6596 assert!(!temp.path.join("providers.json").exists());
6597 assert!(!temp.path.join("memory.json").exists());
6598 assert!(!temp.path.join("subagents.json").exists());
6599
6600 let (salvaged, recovered_fields) = Config::salvage_partial(
6601 r#"{"providers":"schema-invalid-provider-section"}"#,
6602 &temp.path,
6603 )
6604 .expect("object-shaped corrupt config should be salvageable");
6605
6606 assert!(
6607 recovered_fields.is_empty(),
6608 "the schema-invalid provider field must not replace the backup baseline"
6609 );
6610 assert_eq!(
6611 salvaged
6612 .providers()
6613 .anthropic
6614 .as_ref()
6615 .and_then(|provider| provider.model.as_deref()),
6616 Some("claude-backup")
6617 );
6618 assert!(
6619 salvaged
6620 .memory()
6621 .as_ref()
6622 .expect("backup memory config survives")
6623 .auto_dream_enabled
6624 );
6625 assert_eq!(
6626 salvaged.subagents().claude_code_model.as_deref(),
6627 Some("claude-code-backup")
6628 );
6629 }
6630
6631 #[test]
6632 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
6633 let temp = TempHome::new();
6634 std::fs::write(
6637 temp.path.join("config.json.bak"),
6638 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6639 )
6640 .unwrap();
6641 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6642
6643 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6644 assert_eq!(
6645 config.http_proxy, "http://from-backup",
6646 "garbage (non-object) config skips salvage and recovers from .bak"
6647 );
6648 }
6649
6650 #[test]
6651 fn quarantine_files_are_capped_to_newest_n() {
6652 let temp = TempHome::new();
6653 let config_path = temp.path.join("config.json");
6654 std::fs::write(&config_path, "{}").unwrap();
6655
6656 for _ in 0..(QUARANTINE_KEEP + 3) {
6659 quarantine_corrupt_config(&config_path);
6660 std::thread::sleep(std::time::Duration::from_millis(3));
6661 }
6662
6663 let count = std::fs::read_dir(&temp.path)
6664 .unwrap()
6665 .filter_map(|e| e.ok())
6666 .filter(|e| {
6667 e.file_name()
6668 .to_string_lossy()
6669 .starts_with("config.json.corrupted.")
6670 })
6671 .count();
6672 assert_eq!(
6673 count, QUARANTINE_KEEP,
6674 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
6675 );
6676 }
6677
6678 #[test]
6679 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
6680 let temp = TempHome::new();
6681 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
6683 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
6684 std::fs::write(
6685 temp.path.join("config.json.bak.1"),
6686 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
6687 )
6688 .unwrap();
6689
6690 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6691 assert_eq!(
6692 config.http_proxy, "http://from-gen-1",
6693 "recovered from .bak.1 when both config.json and .bak are corrupt"
6694 );
6695 }
6696
6697 #[test]
6698 fn save_rotates_backup_generations() {
6699 let temp = TempHome::new();
6700 let path = temp.path.join("config.json");
6701 std::fs::write(
6703 &path,
6704 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
6705 )
6706 .unwrap();
6707
6708 let mut cfg = Config::create_default();
6709 cfg.http_proxy = "http://proxy-v2".to_string();
6711 cfg.save_to_dir(temp.path.clone()).unwrap();
6712 cfg.http_proxy = "http://proxy-v3".to_string();
6714 cfg.save_to_dir(temp.path.clone()).unwrap();
6715
6716 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6717 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
6718 assert!(
6719 bak.contains("proxy-v2"),
6720 ".bak holds the previous generation (v2)"
6721 );
6722 assert!(
6723 bak1.contains("proxy-v1"),
6724 ".bak.1 holds the older rotated generation (v1)"
6725 );
6726 }
6727
6728 #[test]
6729 fn save_backs_up_existing_config() {
6730 let temp = TempHome::new();
6731 std::fs::write(
6733 temp.path.join("config.json"),
6734 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
6735 )
6736 .unwrap();
6737
6738 let mut config = Config::create_default();
6739 config.http_proxy = "http://new".to_string();
6740 config
6741 .save_to_dir(temp.path.clone())
6742 .expect("save succeeds");
6743
6744 let backup =
6745 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
6746 assert!(
6747 backup.contains("http://old"),
6748 "config.json.bak holds the PREVIOUS config (last-known-good)"
6749 );
6750 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
6751 assert!(
6752 current.contains("http://new"),
6753 "config.json holds the new config"
6754 );
6755 }
6756
6757 #[test]
6758 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
6759 let temp = TempHome::new();
6760 std::fs::write(
6762 temp.path.join("config.json.bak"),
6763 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
6764 )
6765 .unwrap();
6766 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
6769
6770 let mut config = Config::create_default();
6771 config.http_proxy = "http://new".to_string();
6772 config
6773 .save_to_dir(temp.path.clone())
6774 .expect("save succeeds");
6775
6776 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6778 assert!(
6779 backup.contains("http://good-bak"),
6780 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
6781 );
6782 }
6783
6784 #[test]
6787 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
6788 let temp = TempHome::new();
6789 std::fs::write(
6790 temp.path.join("config.json.bak"),
6791 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6792 )
6793 .unwrap();
6794 let corrupt_bytes = "{ not valid json ";
6795 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
6796
6797 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6798 let status = config
6799 .recovery_status()
6800 .expect("a corrupt load must set a pending recovery status");
6801 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
6802 assert_eq!(
6803 status.source,
6804 ConfigRecoverySource::Backup { generation: 0 },
6805 "recovered from generation-0 (.bak)"
6806 );
6807 let quarantine_path = status
6808 .quarantine_path
6809 .as_ref()
6810 .expect("quarantine copy should have succeeded");
6811 assert_eq!(
6812 std::fs::read_to_string(quarantine_path).unwrap(),
6813 corrupt_bytes,
6814 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
6815 );
6816 assert_eq!(
6817 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6818 corrupt_bytes,
6819 "the original config.json itself is untouched by the load (only copied, not moved)"
6820 );
6821 }
6822
6823 #[test]
6824 fn recovery_status_set_from_salvage_lists_recovered_fields() {
6825 let temp = TempHome::new();
6826 temp.set_config_json(
6827 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6828 );
6829
6830 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6831 let status = config.recovery_status().expect("pending recovery");
6832 assert!(!status.confirmed);
6833 match &status.source {
6834 ConfigRecoverySource::Salvaged { fields } => {
6835 assert!(
6836 fields.iter().any(|f| f == "http_proxy"),
6837 "salvaged fields should list the recovered key: {fields:?}"
6838 );
6839 }
6840 other => panic!("expected Salvaged source, got {other:?}"),
6841 }
6842 }
6843
6844 #[test]
6845 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
6846 let temp = TempHome::new();
6847 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6849
6850 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6851 let status = config.recovery_status().expect("pending recovery");
6852 assert!(!status.confirmed);
6853 assert_eq!(status.source, ConfigRecoverySource::Defaults);
6854 }
6855
6856 #[test]
6857 fn clean_load_never_sets_recovery_status() {
6858 let temp = TempHome::new();
6859 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
6860
6861 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6862 assert!(
6863 config.recovery_status().is_none(),
6864 "a config.json that parses cleanly must never carry a pending recovery status"
6865 );
6866 }
6867
6868 #[test]
6869 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
6870 let temp = TempHome::new();
6871 let corrupt_bytes = "}}} broken";
6872 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
6873
6874 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6875 assert!(config.recovery_status().is_some());
6876
6877 let err = config
6878 .save_to_dir(temp.path.clone())
6879 .expect_err("save must refuse while recovery is unconfirmed");
6880 assert!(
6881 err.to_string().contains("recovered from corruption")
6882 || err.to_string().contains("confirm"),
6883 "error should explain the refused overwrite: {err}"
6884 );
6885
6886 assert_eq!(
6889 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6890 corrupt_bytes,
6891 "a refused save must leave the corrupt original untouched"
6892 );
6893 }
6894
6895 #[test]
6896 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
6897 let temp = TempHome::new();
6898 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
6900 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
6901
6902 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6903 let status = config.recovery_status().expect("pending recovery");
6904 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
6905 assert_eq!(
6906 std::fs::read_to_string(quarantine_path).unwrap(),
6907 truncated,
6908 "truncated original preserved byte for byte in quarantine"
6909 );
6910
6911 let err = config.save_to_dir(temp.path.clone());
6912 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
6913 assert_eq!(
6914 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6915 truncated,
6916 "the half-written original stays exactly as it was after a refused save"
6917 );
6918 }
6919
6920 #[test]
6921 fn confirm_recovery_allows_the_next_save() {
6922 let temp = TempHome::new();
6923 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6924
6925 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6926 assert!(config.recovery_status().is_some());
6927
6928 config.confirm_recovery();
6929 assert!(
6930 config.recovery_status().is_some_and(|s| s.confirmed),
6931 "confirm_recovery flips the flag but keeps the status around"
6932 );
6933
6934 config
6935 .save_to_dir(temp.path.clone())
6936 .expect("save must succeed once the recovery is confirmed");
6937 }
6938
6939 #[test]
6940 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
6941 let temp = TempHome::new();
6942 std::fs::write(
6943 temp.path.join("config.json"),
6944 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
6945 )
6946 .unwrap();
6947
6948 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6949 assert!(config.recovery_status().is_some());
6950 let quarantine_path = config
6951 .recovery_status()
6952 .unwrap()
6953 .quarantine_path
6954 .clone()
6955 .unwrap();
6956
6957 config
6958 .confirm_recovery_and_save_to_dir(temp.path.clone())
6959 .expect("confirm+save should succeed");
6960
6961 assert!(
6962 config.recovery_status().is_none(),
6963 "the pending flag is cleared once the recovery is confirmed and persisted"
6964 );
6965 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
6966 assert!(
6967 on_disk.contains("http://recovered"),
6968 "config.json now holds the recovered (salvaged) state"
6969 );
6970 assert!(
6973 quarantine_path.exists(),
6974 "the quarantined original survives confirmation — it's never deleted"
6975 );
6976 }
6977
6978 #[test]
6979 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
6980 let temp = TempHome::new();
6981 let mut config = Config::create_default();
6982 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
6983 assert!(
6984 err.is_err(),
6985 "confirming a recovery that was never pending must error, not silently succeed"
6986 );
6987 }
6988
6989 fn connect_platform_with_encrypted(
6992 platform_type: &str,
6993 token_encrypted: &str,
6994 ) -> ConnectPlatformConfig {
6995 ConnectPlatformConfig {
6996 id: None,
6997 project_id: None,
6998 platform_type: platform_type.to_string(),
6999 token: None,
7000 token_encrypted: Some(token_encrypted.to_string()),
7001 token_credential_ref: None,
7002 token_configured: false,
7003 app_id: None,
7004 app_secret: None,
7005 app_secret_encrypted: None,
7006 app_secret_credential_ref: None,
7007 app_secret_configured: false,
7008 domain: None,
7009 allow_from: vec!["user-1".to_string()],
7010 admin_from: Vec::new(),
7011 }
7012 }
7013
7014 fn connect_json_path(temp: &TempHome) -> PathBuf {
7015 temp.path.join("connect.json")
7016 }
7017
7018 #[test]
7019 fn save_splits_connect_into_sibling_connect_json() {
7020 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7021 let temp = TempHome::new();
7022
7023 let mut config = Config::create_default();
7024 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7025 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7026
7027 config
7028 .save_to_dir(temp.path.clone())
7029 .expect("save succeeds");
7030
7031 let config_json: serde_json::Value =
7032 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7033 .unwrap();
7034 assert!(
7035 config_json.get("connect").is_none(),
7036 "config.json must not carry the `connect` key after a save"
7037 );
7038
7039 let connect_json: serde_json::Value =
7040 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7041 .unwrap();
7042 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7043 assert!(
7044 connect_json["platforms"][0]["token_encrypted"]
7045 .as_str()
7046 .is_some_and(|v| !v.is_empty()),
7047 "the token is persisted in its encrypted form in connect.json"
7048 );
7049 assert!(
7050 connect_json["platforms"][0].get("token").is_none(),
7051 "the plaintext token is never persisted (skip_serializing)"
7052 );
7053 }
7054
7055 #[test]
7058 fn save_assigns_a_missing_connect_platform_id() {
7059 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7060 let temp = TempHome::new();
7061
7062 let mut config = Config::create_default();
7063 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7064 assert!(
7065 config.connect.platforms[0].id.is_none(),
7066 "precondition: the entry starts without an id"
7067 );
7068
7069 config
7070 .save_to_dir(temp.path.clone())
7071 .expect("save succeeds");
7072
7073 let connect_json: serde_json::Value =
7074 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7075 .unwrap();
7076 let persisted_id = connect_json["platforms"][0]["id"]
7077 .as_str()
7078 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7079 assert!(!persisted_id.is_empty());
7080
7081 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7082 assert_eq!(
7083 reloaded.connect.platforms[0].id.as_deref(),
7084 Some(persisted_id),
7085 "the assigned id round-trips through a reload"
7086 );
7087 }
7088
7089 #[test]
7090 fn save_never_reassigns_an_existing_connect_platform_id() {
7091 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7092 let temp = TempHome::new();
7093
7094 let mut config = Config::create_default();
7095 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7096 platform.id = Some("stable-id-123".to_string());
7097 config.connect.platforms = vec![platform];
7098
7099 config
7100 .save_to_dir(temp.path.clone())
7101 .expect("first save succeeds");
7102 config
7104 .save_to_dir(temp.path.clone())
7105 .expect("second save succeeds");
7106
7107 let connect_json: serde_json::Value =
7108 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7109 .unwrap();
7110 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7111 }
7112
7113 #[test]
7114 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7115 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7116 let temp = TempHome::new();
7117
7118 let mut config = Config::create_default();
7119 config.connect.platforms = vec![
7120 connect_platform_with_encrypted("telegram", "cipher-a"),
7121 connect_platform_with_encrypted("telegram", "cipher-b"),
7122 ];
7123
7124 config
7125 .save_to_dir(temp.path.clone())
7126 .expect("save succeeds");
7127
7128 let connect_json: serde_json::Value =
7129 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7130 .unwrap();
7131 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7132 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7133 assert_ne!(
7134 id_a, id_b,
7135 "two entries sharing platform_type must still get distinct ids"
7136 );
7137 }
7138
7139 #[test]
7140 fn load_never_assigns_or_persists_an_id_by_itself() {
7141 let temp = TempHome::new();
7142 std::fs::write(
7143 connect_json_path(&temp),
7144 serde_json::json!({
7145 "platforms": [
7146 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7147 ]
7148 })
7149 .to_string(),
7150 )
7151 .unwrap();
7152 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7153
7154 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7155
7156 assert!(
7157 config.connect.platforms[0].id.is_none(),
7158 "load alone must not backfill an id in memory"
7159 );
7160 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7161 assert_eq!(
7162 connect_json_before, connect_json_after,
7163 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7164 );
7165 }
7166
7167 #[test]
7168 fn load_merges_connect_json_into_config() {
7169 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7170 let temp = TempHome::new();
7171 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7172 std::fs::write(
7173 connect_json_path(&temp),
7174 serde_json::json!({
7175 "platforms": [
7176 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7177 ]
7178 })
7179 .to_string(),
7180 )
7181 .unwrap();
7182
7183 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7184 assert_eq!(config.connect.platforms.len(), 1);
7185 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7186 assert_eq!(
7187 config.connect.platforms[0].token.as_deref(),
7188 Some("connect-secret")
7189 );
7190 assert!(config.connect.platforms[0].token_encrypted.is_none());
7191 }
7192
7193 #[test]
7194 fn load_without_connect_json_yields_empty_inert_connect_config() {
7195 let temp = TempHome::new();
7196 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7197
7198 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7199 assert!(
7200 config.connect.platforms.is_empty(),
7201 "no connect.json and no legacy key -> empty/inert connect config"
7202 );
7203 assert!(
7204 !connect_json_path(&temp).exists(),
7205 "load must not create connect.json when there is nothing to migrate"
7206 );
7207 }
7208
7209 #[test]
7210 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7211 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7212 let temp = TempHome::new();
7213 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7214 let legacy_cipher_for_assert = legacy_cipher.clone();
7215 temp.set_config_json(
7217 &serde_json::json!({
7218 "http_proxy": "http://keep-me",
7219 "connect": {
7220 "platforms": [
7221 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7222 ]
7223 }
7224 })
7225 .to_string(),
7226 );
7227
7228 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7229
7230 assert_eq!(config.connect.platforms.len(), 1);
7232 assert_eq!(
7233 config.connect.platforms[0].token.as_deref(),
7234 Some("legacy-connect-secret")
7235 );
7236 assert_eq!(config.http_proxy, "http://keep-me");
7238
7239 assert!(
7241 connect_json_path(&temp).exists(),
7242 "migration proactively creates connect.json"
7243 );
7244 let connect_json: serde_json::Value =
7245 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7246 .unwrap();
7247 assert_eq!(
7248 connect_json["platforms"][0]["token_encrypted"],
7249 legacy_cipher_for_assert
7250 );
7251
7252 let config_json: serde_json::Value =
7254 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7255 .unwrap();
7256 assert!(
7257 config_json.get("connect").is_none(),
7258 "config.json is rewritten without the legacy `connect` key"
7259 );
7260 }
7261
7262 #[test]
7270 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7271 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7272 let temp = TempHome::new();
7273
7274 let original_api_key_encrypted =
7275 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7276 temp.set_config_json(
7277 &serde_json::json!({
7278 "providers": {
7279 "openai": {
7280 "api_key_encrypted": original_api_key_encrypted,
7281 }
7282 },
7283 "connect": {
7284 "platforms": [
7285 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7286 ]
7287 }
7288 })
7289 .to_string(),
7290 );
7291
7292 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7293 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7294
7295 assert!(
7298 !temp.path.join("config.json.bak").exists(),
7299 "a read-only load migrating a legacy `connect` key must not rotate \
7300 config.json backups"
7301 );
7302
7303 let config_json: serde_json::Value =
7308 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7309 .unwrap();
7310 assert_eq!(
7311 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7312 "an unrelated secret's ciphertext must not be touched by the connect \
7313 migration's narrow write"
7314 );
7315 assert!(
7316 config_json.get("connect").is_none(),
7317 "config.json is still rewritten without the legacy `connect` key"
7318 );
7319 }
7320
7321 #[test]
7322 fn both_files_present_connect_json_wins() {
7323 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7324 let temp = TempHome::new();
7325 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7326 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7327 temp.set_config_json(
7328 &serde_json::json!({
7329 "connect": {
7330 "platforms": [
7331 { "type": "telegram", "token_encrypted": stale }
7332 ]
7333 }
7334 })
7335 .to_string(),
7336 );
7337 std::fs::write(
7338 connect_json_path(&temp),
7339 serde_json::json!({
7340 "platforms": [
7341 { "type": "telegram", "token_encrypted": authoritative }
7342 ]
7343 })
7344 .to_string(),
7345 )
7346 .unwrap();
7347
7348 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7349 assert_eq!(
7350 config.connect.platforms[0].token.as_deref(),
7351 Some("authoritative-secret"),
7352 "connect.json wins over a stale legacy config.json key"
7353 );
7354 }
7355
7356 #[test]
7361 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7362 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7363 let temp = TempHome::new();
7364 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7365 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7366 temp.set_config_json(
7367 &serde_json::json!({
7368 "http_proxy": "http://keep-me",
7369 "connect": {
7370 "platforms": [
7371 { "type": "telegram", "token_encrypted": stale }
7372 ]
7373 }
7374 })
7375 .to_string(),
7376 );
7377 std::fs::write(
7378 connect_json_path(&temp),
7379 serde_json::json!({
7380 "platforms": [
7381 { "type": "telegram", "token_encrypted": authoritative }
7382 ]
7383 })
7384 .to_string(),
7385 )
7386 .unwrap();
7387
7388 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7389 assert_eq!(
7390 config.connect.platforms[0].token.as_deref(),
7391 Some("authoritative-secret")
7392 );
7393 assert_eq!(config.http_proxy, "http://keep-me");
7395
7396 let config_json: serde_json::Value =
7397 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7398 .unwrap();
7399 assert!(
7400 config_json.get("connect").is_none(),
7401 "the stale legacy `connect` key must be stripped from config.json \
7402 immediately on load, not left for the next natural save"
7403 );
7404 }
7405
7406 #[test]
7414 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7415 let temp = TempHome::new();
7416 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7417 std::fs::write(
7418 temp.path.join("config.json.bak"),
7419 serde_json::json!({
7420 "http_proxy": "http://old",
7421 "connect": {
7422 "platforms": [
7423 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7424 ]
7425 }
7426 })
7427 .to_string(),
7428 )
7429 .unwrap();
7430
7431 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7432 assert!(config.connect.platforms.is_empty());
7435
7436 let bak: serde_json::Value = serde_json::from_str(
7437 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7438 )
7439 .unwrap();
7440 assert!(
7441 bak.get("connect").is_none(),
7442 "the legacy `connect` key must be stripped from the tainted .bak generation"
7443 );
7444 assert_eq!(
7445 bak["http_proxy"], "http://old",
7446 "every other key in the .bak generation survives the scrub byte-for-byte in content"
7447 );
7448 }
7449
7450 #[test]
7455 fn scrub_reaches_all_rotated_generations() {
7456 let temp = TempHome::new();
7457 temp.set_config_json(&serde_json::json!({}).to_string());
7458 for (gen_suffix, cipher) in [
7459 ("config.json.bak", "cipher-gen0"),
7460 ("config.json.bak.1", "cipher-gen1"),
7461 ("config.json.bak.2", "cipher-gen2"),
7462 ] {
7463 std::fs::write(
7464 temp.path.join(gen_suffix),
7465 serde_json::json!({
7466 "connect": {
7467 "platforms": [
7468 { "type": "telegram", "token_encrypted": cipher }
7469 ]
7470 }
7471 })
7472 .to_string(),
7473 )
7474 .unwrap();
7475 }
7476
7477 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7478
7479 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7480 let value: serde_json::Value =
7481 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7482 .unwrap();
7483 assert!(
7484 value.get("connect").is_none(),
7485 "{gen_suffix} must have its legacy `connect` key stripped"
7486 );
7487 }
7488 }
7489
7490 #[test]
7498 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7499 let temp = TempHome::new();
7500 temp.set_config_json(&serde_json::json!({}).to_string());
7501 let bak_path = temp.path.join("config.json.bak");
7502 std::fs::write(
7503 &bak_path,
7504 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7505 )
7506 .unwrap();
7507
7508 let before_bytes = std::fs::read(&bak_path).unwrap();
7509 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7510
7511 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7515
7516 let after_bytes = std::fs::read(&bak_path).unwrap();
7517 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7518 assert_eq!(
7519 before_bytes, after_bytes,
7520 "a .bak generation without a legacy `connect` key must not be rewritten at all"
7521 );
7522 assert_eq!(
7523 before_mtime, after_mtime,
7524 "no write means no mtime change either"
7525 );
7526 }
7527
7528 #[test]
7533 fn scrub_skips_unparsable_backup_without_deleting_it() {
7534 let temp = TempHome::new();
7535 temp.set_config_json(&serde_json::json!({}).to_string());
7536 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
7537
7538 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7539
7540 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7541 assert_eq!(
7542 content, "{ not valid json",
7543 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
7544 );
7545 }
7546
7547 #[test]
7551 fn scrub_tolerates_missing_generations() {
7552 let temp = TempHome::new();
7553 temp.set_config_json(&serde_json::json!({}).to_string());
7554 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7556 assert!(config.connect.platforms.is_empty());
7557 assert!(!temp.path.join("config.json.bak").exists());
7558 }
7559
7560 #[test]
7563 fn scrub_does_not_break_backup_rotation() {
7564 let temp = TempHome::new();
7565 std::fs::write(
7566 temp.path.join("config.json"),
7567 serde_json::json!({
7568 "http_proxy": "http://proxy-v1",
7569 "connect": {
7570 "platforms": [
7571 { "type": "telegram", "token_encrypted": "legacy-cipher" }
7572 ]
7573 }
7574 })
7575 .to_string(),
7576 )
7577 .unwrap();
7578 std::fs::write(
7579 temp.path.join("config.json.bak"),
7580 serde_json::json!({
7581 "http_proxy": "http://proxy-v0",
7582 "connect": {
7583 "platforms": [
7584 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
7585 ]
7586 }
7587 })
7588 .to_string(),
7589 )
7590 .unwrap();
7591
7592 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7594 let bak: serde_json::Value = serde_json::from_str(
7595 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7596 )
7597 .unwrap();
7598 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
7599
7600 config.http_proxy = "http://proxy-v2".to_string();
7603 config.save_to_dir(temp.path.clone()).unwrap();
7604
7605 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7606 assert!(
7607 new_bak.contains("proxy-v1"),
7608 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
7609 );
7610 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7611 assert!(
7612 new_bak1.contains("proxy-v0"),
7613 ".bak.1 holds the scrubbed older generation after rotation"
7614 );
7615 assert!(
7616 !new_bak1.contains("legacy-bak-cipher"),
7617 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
7618 stripped secret"
7619 );
7620 }
7621
7622 #[test]
7623 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
7624 let temp = TempHome::new();
7625 temp.set_config_json("{}");
7626 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7627
7628 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7629 assert!(
7630 config.connect.platforms.is_empty(),
7631 "corrupt connect.json fails SAFE to an empty/inert connect config"
7632 );
7633
7634 let backup = connect_json_path(&temp).with_extension("json.bak");
7635 assert!(
7636 backup.exists(),
7637 "the corrupt connect.json is quarantined to connect.json.bak"
7638 );
7639 assert!(
7640 std::fs::read_to_string(backup)
7641 .unwrap()
7642 .contains("not valid json"),
7643 "the quarantined copy holds the bad content"
7644 );
7645 assert!(
7650 !connect_json_path(&temp).exists(),
7651 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
7652 connect.json should remain after quarantine"
7653 );
7654 }
7655
7656 #[test]
7657 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
7658 let temp = TempHome::new();
7659 temp.set_config_json(
7662 &serde_json::json!({
7663 "connect": {
7664 "platforms": [
7665 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
7666 ]
7667 }
7668 })
7669 .to_string(),
7670 );
7671 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7672
7673 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7674 assert!(
7675 config.connect.platforms.is_empty(),
7676 "corrupt connect.json must not fall back to the legacy config.json copy"
7677 );
7678 }
7679
7680 #[test]
7681 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
7682 let temp = TempHome::new();
7683 let config = Config::create_default();
7684 assert!(config.connect.platforms.is_empty());
7685
7686 config
7687 .save_to_dir(temp.path.clone())
7688 .expect("save succeeds");
7689
7690 assert!(
7691 !connect_json_path(&temp).exists(),
7692 "an empty connect config with no pre-existing file must not create one"
7693 );
7694 }
7695
7696 #[test]
7697 fn connect_json_backed_up_before_overwrite() {
7698 let temp = TempHome::new();
7699 std::fs::write(
7700 connect_json_path(&temp),
7701 serde_json::json!({
7702 "platforms": [
7703 { "type": "telegram", "token_encrypted": "old-cipher" }
7704 ]
7705 })
7706 .to_string(),
7707 )
7708 .unwrap();
7709
7710 let mut config = Config::create_default();
7711 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
7712 config
7713 .save_to_dir(temp.path.clone())
7714 .expect("save succeeds");
7715
7716 let backup = connect_json_path(&temp).with_extension("json.bak");
7717 assert!(
7718 std::fs::read_to_string(backup)
7719 .unwrap()
7720 .contains("old-cipher"),
7721 "the previous connect.json is preserved as connect.json.bak before the overwrite"
7722 );
7723 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7724 assert!(current.contains("new-cipher"));
7725 }
7726
7727 #[test]
7730 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
7731 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7732 let temp = TempHome::new();
7733
7734 let mut config = Config::create_default();
7735 config.connect.platforms = vec![ConnectPlatformConfig {
7736 id: None,
7737 project_id: None,
7738 platform_type: "feishu".to_string(),
7739 token: None,
7740 token_encrypted: None,
7741 token_credential_ref: None,
7742 token_configured: false,
7743 app_id: Some("cli_real_app_id".to_string()),
7744 app_secret: Some("plain-app-secret".to_string()),
7745 app_secret_encrypted: None,
7746 app_secret_credential_ref: None,
7747 app_secret_configured: false,
7748 domain: Some("lark".to_string()),
7749 allow_from: vec!["ou_1".to_string()],
7750 admin_from: Vec::new(),
7751 }];
7752
7753 config
7754 .save_to_dir(temp.path.clone())
7755 .expect("save succeeds");
7756
7757 let connect_json: serde_json::Value =
7758 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7759 .unwrap();
7760 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
7761 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
7762 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
7763 assert!(
7764 connect_json["platforms"][0]["app_secret_encrypted"]
7765 .as_str()
7766 .is_some_and(|v| !v.is_empty()),
7767 "app_secret is persisted in its encrypted form in connect.json"
7768 );
7769 assert!(
7770 connect_json["platforms"][0].get("app_secret").is_none(),
7771 "the plaintext app_secret is never persisted (skip_serializing)"
7772 );
7773 }
7774
7775 #[test]
7776 fn load_hydrates_feishu_app_secret_from_encrypted() {
7777 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7778 let temp = TempHome::new();
7779
7780 let mut config = Config::create_default();
7781 config.connect.platforms = vec![ConnectPlatformConfig {
7782 id: None,
7783 project_id: None,
7784 platform_type: "feishu".to_string(),
7785 token: None,
7786 token_encrypted: None,
7787 token_credential_ref: None,
7788 token_configured: false,
7789 app_id: Some("cli_real_app_id".to_string()),
7790 app_secret: Some("plain-app-secret".to_string()),
7791 app_secret_encrypted: None,
7792 app_secret_credential_ref: None,
7793 app_secret_configured: false,
7794 domain: Some("lark".to_string()),
7795 allow_from: vec!["ou_1".to_string()],
7796 admin_from: Vec::new(),
7797 }];
7798 config
7799 .save_to_dir(temp.path.clone())
7800 .expect("save succeeds");
7801
7802 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7803 assert_eq!(reloaded.connect.platforms.len(), 1);
7804 assert_eq!(
7805 reloaded.connect.platforms[0].app_secret.as_deref(),
7806 Some("plain-app-secret"),
7807 "reload hydrates app_secret from app_secret_encrypted"
7808 );
7809 assert_eq!(
7810 reloaded.connect.platforms[0].app_id.as_deref(),
7811 Some("cli_real_app_id")
7812 );
7813 assert_eq!(
7814 reloaded.connect.platforms[0].domain.as_deref(),
7815 Some("lark")
7816 );
7817 }
7818
7819 #[test]
7820 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
7821 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
7822 let temp = TempHome::new();
7823 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
7824 std::fs::write(
7825 connect_json_path(&temp),
7826 serde_json::json!({
7827 "platforms": [
7828 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7829 ]
7830 })
7831 .to_string(),
7832 )
7833 .unwrap();
7834
7835 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7836
7837 assert_eq!(config.connect.platforms.len(), 1);
7838 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7839 assert_eq!(
7840 config.connect.platforms[0].token.as_deref(),
7841 Some("legacy-telegram-secret")
7842 );
7843 assert_eq!(
7844 config.connect.platforms[0].app_id, None,
7845 "a legacy entry with no Feishu fields deserializes them as None"
7846 );
7847 assert_eq!(config.connect.platforms[0].app_secret, None);
7848 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
7849 assert_eq!(config.connect.platforms[0].domain, None);
7850 }
7851
7852 #[test]
7853 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
7854 let _lock = env_lock_acquire();
7855 let temp_home = TempHome::new();
7856 temp_home.set_config_json(
7857 r#"{
7858 "provider": "openai",
7859 "providers": {
7860 "openai": {
7861 "api_key": "sk-test",
7862 "model": "gpt-4o"
7863 }
7864 }
7865}"#,
7866 );
7867
7868 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7869 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7870
7871 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7872
7873 assert_eq!(
7874 config
7875 .providers
7876 .openai
7877 .as_ref()
7878 .and_then(|c| c.model.as_deref()),
7879 Some("gpt-4o")
7880 );
7881 assert!(
7882 config.http_proxy.is_empty(),
7883 "config should keep http_proxy empty when field is omitted"
7884 );
7885 assert!(
7886 config.https_proxy.is_empty(),
7887 "config should keep https_proxy empty when field is omitted"
7888 );
7889 }
7890
7891 #[test]
7892 fn get_memory_background_model_prefers_memory_specific_override() {
7893 let mut config = Config::default();
7894 config.features.provider_model_ref = false;
7895 config.provider = "openai".to_string();
7896 config.providers.openai = Some(OpenAIConfig {
7897 api_key: "test".to_string(),
7898 api_key_encrypted: None,
7899 credential_ref: None,
7900 base_url: None,
7901 model: Some("gpt-main".to_string()),
7902 fast_model: Some("gpt-fast".to_string()),
7903 vision_model: None,
7904 reasoning_effort: None,
7905 responses_only_models: vec![],
7906 request_overrides: None,
7907 extra: BTreeMap::new(),
7908 api_key_from_env: false,
7909 });
7910 config.memory.0 = Some(MemoryConfig {
7911 background_model: Some("memory-fast".to_string()),
7912 ..MemoryConfig::default()
7913 });
7914
7915 assert_eq!(
7916 config.get_memory_background_model().as_deref(),
7917 Some("memory-fast")
7918 );
7919 }
7920
7921 #[test]
7922 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
7923 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
7928 api_key: api_key.to_string(),
7929 api_key_encrypted: None,
7930 credential_ref: None,
7931 base_url: None,
7932 model: None,
7933 fast_model: None,
7934 vision_model: None,
7935 reasoning_effort: None,
7936 responses_only_models: vec![],
7937 request_overrides: None,
7938 extra: BTreeMap::new(),
7939 api_key_from_env: from_env,
7940 };
7941
7942 let mut current = Config::default();
7944 current.providers.openai = Some(openai("sk-env", true));
7945 let mut merged = Config::default();
7946 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
7948 let got = merged.providers.openai.as_ref().unwrap();
7949 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
7950 assert!(got.api_key_from_env, "env flag restored");
7951
7952 let mut merged = Config::default();
7954 merged.providers.openai = Some(openai("sk-explicit", false));
7955 merged.preserve_env_sourced_provider_keys(¤t);
7956 assert_eq!(
7957 merged.providers.openai.as_ref().unwrap().api_key,
7958 "sk-explicit",
7959 "explicit patch key must win"
7960 );
7961
7962 let mut current_plain = Config::default();
7964 current_plain.providers.openai = Some(openai("sk-plain", false));
7965 let mut merged = Config::default();
7966 merged.providers.openai = Some(openai("", false));
7967 merged.preserve_env_sourced_provider_keys(¤t_plain);
7968 assert!(
7969 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
7970 "non-env key must not be restored by this path"
7971 );
7972 }
7973
7974 #[test]
7975 fn refresh_preserves_ciphertext_when_plaintext_empty() {
7976 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
7980 api_key: api_key.to_string(),
7981 api_key_encrypted: enc.map(str::to_string),
7982 credential_ref: None,
7983 base_url: None,
7984 model: None,
7985 fast_model: None,
7986 vision_model: None,
7987 reasoning_effort: None,
7988 responses_only_models: vec![],
7989 request_overrides: None,
7990 extra: BTreeMap::new(),
7991 api_key_from_env: false,
7992 };
7993
7994 let mut config = Config::default();
7996 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
7997 config
7998 .refresh_provider_api_keys_encrypted()
7999 .expect("refresh");
8000 assert_eq!(
8001 config
8002 .providers
8003 .openai
8004 .as_ref()
8005 .unwrap()
8006 .api_key_encrypted
8007 .as_deref(),
8008 Some("preexisting-ciphertext"),
8009 "existing ciphertext must be preserved when plaintext is empty"
8010 );
8011
8012 let mut config = Config::default();
8014 config.providers.openai = Some(openai("", None));
8015 config
8016 .refresh_provider_api_keys_encrypted()
8017 .expect("refresh");
8018 assert!(
8019 config
8020 .providers
8021 .openai
8022 .as_ref()
8023 .unwrap()
8024 .api_key_encrypted
8025 .is_none(),
8026 "no key + no ciphertext should stay None"
8027 );
8028
8029 let mut config = Config::default();
8031 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8032 config
8033 .refresh_provider_api_keys_encrypted()
8034 .expect("refresh");
8035 let enc = config
8036 .providers
8037 .openai
8038 .as_ref()
8039 .unwrap()
8040 .api_key_encrypted
8041 .clone()
8042 .expect("ciphertext present");
8043 assert!(
8044 !enc.is_empty() && enc != "stale-ciphertext",
8045 "plaintext re-encrypted"
8046 );
8047 }
8048
8049 #[test]
8050 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8051 let mut config = Config::default();
8057 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8058 "provider_type": "openai",
8059 "api_key": "sk-instance-live",
8060 }))
8061 .expect("valid instance");
8062 config
8063 .provider_instances
8064 .insert("work".to_string(), instance);
8065
8066 config.refresh_encrypted_secrets().expect("refresh");
8067 assert!(
8068 config.provider_instances["work"]
8069 .api_key_encrypted
8070 .is_some(),
8071 "live config must hold ciphertext after refresh"
8072 );
8073
8074 let value = serde_json::to_value(&config).expect("serialize");
8076 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8077 assert!(
8078 back.provider_instances["work"].api_key.is_empty(),
8079 "plaintext is skip_serializing"
8080 );
8081 back.hydrate_provider_instance_api_keys_from_encrypted();
8082 assert_eq!(
8083 back.provider_instances["work"].api_key, "sk-instance-live",
8084 "key must be recoverable from the round-tripped ciphertext"
8085 );
8086 }
8087
8088 #[test]
8089 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8090 let mut config = Config::default();
8091 config.features.provider_model_ref = false;
8092 config.provider = "openai".to_string();
8093 config.providers.openai = Some(OpenAIConfig {
8094 api_key: "test".to_string(),
8095 api_key_encrypted: None,
8096 credential_ref: None,
8097 base_url: None,
8098 model: Some("gpt-main".to_string()),
8099 fast_model: Some("gpt-fast".to_string()),
8100 vision_model: None,
8101 reasoning_effort: None,
8102 responses_only_models: vec![],
8103 request_overrides: None,
8104 extra: BTreeMap::new(),
8105 api_key_from_env: false,
8106 });
8107
8108 assert_eq!(
8109 config.get_memory_background_model().as_deref(),
8110 Some("gpt-fast")
8111 );
8112 }
8113
8114 #[test]
8115 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8116 let mut config = Config::default();
8117 config.features.provider_model_ref = false;
8118 config.provider = "openai".to_string();
8119 config.providers.openai = Some(OpenAIConfig {
8120 api_key: "sk-stale".to_string(),
8121 model: Some("legacy-main".to_string()),
8122 fast_model: Some("legacy-fast".to_string()),
8123 vision_model: Some("legacy-vision".to_string()),
8124 reasoning_effort: Some(ReasoningEffort::Low),
8125 ..OpenAIConfig::default()
8126 });
8127 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8128 "provider_type": "openai",
8129 "model": "instance-main",
8130 "fast_model": "instance-fast",
8131 "vision_model": "instance-vision",
8132 "reasoning_effort": "high",
8133 "enabled": true
8134 }))
8135 .unwrap();
8136 instance.api_key = "sk-instance".to_string();
8137 config
8138 .provider_instances
8139 .insert("work".to_string(), instance);
8140 config.default_provider_instance = Some("work".to_string());
8141
8142 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8143 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8144 assert_eq!(
8145 config.get_memory_background_model().as_deref(),
8146 Some("instance-fast")
8147 );
8148 assert_eq!(
8149 config.get_task_summary_model().as_deref(),
8150 Some("instance-fast")
8151 );
8152 assert_eq!(
8153 config.get_vision_model().as_deref(),
8154 Some("instance-vision")
8155 );
8156 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8157 }
8158
8159 #[test]
8160 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8161 let _provider =
8162 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8163 let _openai_key = crate::test_support::override_runtime_env_var(
8164 "BAMBOO_OPENAI_API_KEY",
8165 Some("sk-runtime-env-instance"),
8166 );
8167 let _anthropic_key =
8168 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8169 let _gemini_key =
8170 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8171
8172 let mut config = Config::default();
8173 *config.providers_mut() = ProviderConfigs::default();
8174 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8175 let mut extra = serde_json::Map::new();
8176 if from_environment {
8177 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8178 }
8179 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8180 extra.insert("enabled".to_string(), serde_json::json!(true));
8181 config.provider_instances.insert(
8182 id.to_string(),
8183 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8184 );
8185 }
8186
8187 config.apply_runtime_env_overrides();
8188
8189 assert_eq!(
8190 config.default_provider_instance.as_deref(),
8191 Some("a-marked"),
8192 "type override selects the lexicographically first enabled instance"
8193 );
8194 assert_eq!(
8195 config.provider_instances["a-marked"].api_key,
8196 "sk-runtime-env-instance"
8197 );
8198 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8199 assert!(
8200 config.providers().openai.is_none(),
8201 "instance-mode env hydration must not recreate a legacy alias"
8202 );
8203
8204 config.refresh_encrypted_secrets().unwrap();
8205 let serialized = serde_json::to_string(&config).unwrap();
8206 assert!(!serialized.contains("sk-runtime-env-instance"));
8207 assert!(
8208 config.provider_instances["a-marked"]
8209 .api_key_encrypted
8210 .is_none(),
8211 "runtime env plaintext must not be encrypted into ordinary config"
8212 );
8213 }
8214
8215 #[test]
8216 fn runtime_provider_override_prefers_exact_instance_id() {
8217 let _provider =
8218 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8219 let _openai_key =
8220 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8221 let _anthropic_key =
8222 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8223 let _gemini_key =
8224 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8225 let mut config = Config::default();
8226 for id in ["work", "personal"] {
8227 config.provider_instances.insert(
8228 id.to_string(),
8229 serde_json::from_value(serde_json::json!({
8230 "provider_type": "openai",
8231 "enabled": true
8232 }))
8233 .unwrap(),
8234 );
8235 }
8236
8237 config.apply_runtime_env_overrides();
8238 assert_eq!(
8239 config.default_provider_instance.as_deref(),
8240 Some("personal")
8241 );
8242 }
8243
8244 #[test]
8245 fn get_memory_background_model_does_not_fall_back_to_main_model() {
8246 let mut config = Config::default();
8247 config.features.provider_model_ref = false;
8248 config.provider = "openai".to_string();
8249 config.providers.openai = Some(OpenAIConfig {
8250 api_key: "test".to_string(),
8251 api_key_encrypted: None,
8252 credential_ref: None,
8253 base_url: None,
8254 model: Some("gpt-main".to_string()),
8255 fast_model: None,
8256 vision_model: None,
8257 reasoning_effort: None,
8258 responses_only_models: vec![],
8259 request_overrides: None,
8260 extra: BTreeMap::new(),
8261 api_key_from_env: false,
8262 });
8263
8264 assert!(config.get_memory_background_model().is_none());
8265 }
8266
8267 #[test]
8268 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8269 let legacy = serde_json::json!({
8270 "memory": MemoryConfig {
8271 background_model: Some("dream-fast".to_string()),
8272 summary_target_ratio: 0.20,
8273 summary_safe_window_percent: 80,
8274 auto_dream_enabled: true,
8275 auto_dream_interval_secs: 900,
8276 project_prompt_injection: false,
8277 relevant_recall: false,
8278 relevant_recall_rerank: true,
8279 project_first_dream: false,
8280 ledger_agenda_injection: false,
8281 ledger_gardener_enabled: false,
8282 ledger_gardener_interval_secs: 7_200,
8283 ledger_distillation_enabled: false,
8284 dream_refine_mode: true,
8285 gardener_enabled: true,
8286 gardener_interval_secs: 3_600,
8287 gardener_volume_trigger: 40,
8288 gardener_max_splits_per_run: 4,
8289 gardener_min_sections: 7,
8290 dedup_gardener_enabled: true,
8291 dedup_gardener_min_score: 0.7,
8292 dedup_gardener_max_merges_per_run: 3,
8293 memory_active_capacity: 500,
8294 capacity_max_archivals_per_run: 10,
8295 granularity_freshness_gardener_enabled: false,
8296 }
8297 });
8298 let config: Config = serde_json::from_value(legacy).unwrap();
8299
8300 let serialized = serde_json::to_value(&config).expect("config should serialize");
8301 assert!(serialized.get("memory").is_some());
8302 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8303 assert!(round_tripped
8304 .memory()
8305 .as_ref()
8306 .is_some_and(|memory| memory.dream_refine_mode));
8307 let memory = config.memory.as_ref().expect("memory config should exist");
8308 assert!(memory.auto_dream_enabled);
8309 assert!(!memory.project_prompt_injection);
8310 assert!(!memory.relevant_recall);
8311 assert!(memory.relevant_recall_rerank);
8312 assert!(!memory.project_first_dream);
8313 assert!(memory.dream_refine_mode);
8314 assert!(memory.gardener_enabled);
8315 assert_eq!(memory.gardener_interval_secs, 3_600);
8316 assert_eq!(memory.gardener_volume_trigger, 40);
8317 assert_eq!(memory.gardener_max_splits_per_run, 4);
8318 assert_eq!(memory.gardener_min_sections, 7);
8319 assert!(memory.dedup_gardener_enabled);
8320 assert_eq!(memory.dedup_gardener_min_score, 0.7);
8321 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8322 assert_eq!(memory.memory_active_capacity, 500);
8323 assert_eq!(memory.capacity_max_archivals_per_run, 10);
8324 assert!(!memory.granularity_freshness_gardener_enabled);
8325 }
8326
8327 #[test]
8329 fn memory_active_capacity_defaults_off() {
8330 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8331 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8332 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8333 let memory = parsed.memory.as_ref().unwrap();
8334 assert_eq!(memory.memory_active_capacity, 0);
8335 assert_eq!(
8336 memory.capacity_max_archivals_per_run, 50,
8337 "omitted field takes the serde default fn"
8338 );
8339 }
8340
8341 #[test]
8342 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8343 let defaults = MemoryConfig::default();
8344 assert_eq!(defaults.summary_target_ratio, 0.20);
8345 assert_eq!(defaults.summary_safe_window_percent, 80);
8346
8347 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8348 let memory = parsed.memory.as_ref().expect("memory present");
8349 assert_eq!(memory.summary_target_ratio, 0.20);
8350 assert_eq!(memory.summary_safe_window_percent, 80);
8351 }
8352
8353 #[test]
8357 fn memory_maintenance_integrators_default_on() {
8358 let defaults = MemoryConfig::default();
8359 assert!(defaults.auto_dream_enabled);
8360 assert!(defaults.gardener_enabled);
8361 assert!(defaults.dedup_gardener_enabled);
8362 assert_eq!(defaults.gardener_volume_trigger, 25);
8363
8364 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8366 let memory = parsed.memory.as_ref().expect("memory present");
8367 assert!(
8368 memory.auto_dream_enabled,
8369 "auto_dream on when field omitted"
8370 );
8371 assert!(memory.gardener_enabled, "gardener on when field omitted");
8372 assert!(
8373 memory.dedup_gardener_enabled,
8374 "dedup gardener on when field omitted"
8375 );
8376 let opted_out: Config =
8378 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8379 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8380 }
8381
8382 #[test]
8383 fn memory_config_env_overrides_prompt_flags() {
8384 let _lock = env_lock_acquire();
8385 let temp_home = TempHome::new();
8386 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8387 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8388 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8389 let _relevant_recall_rerank =
8390 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8391 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8392
8393 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8394 let memory = config
8395 .memory
8396 .as_ref()
8397 .expect("memory config should be created by env overrides");
8398 assert!(!memory.project_prompt_injection);
8399 assert!(!memory.relevant_recall);
8400 assert!(memory.relevant_recall_rerank);
8401 assert!(!memory.project_first_dream);
8402 }
8403
8404 #[test]
8405 fn provider_api_keys_injected_from_env_and_never_persisted() {
8406 let _lock = env_lock_acquire();
8407 let temp_home = TempHome::new();
8408 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8409 let _anthropic = crate::test_support::override_runtime_env_var(
8410 "BAMBOO_ANTHROPIC_API_KEY",
8411 Some("sk-ant-from-env"),
8412 );
8413 let _openai = crate::test_support::override_runtime_env_var(
8414 "BAMBOO_OPENAI_API_KEY",
8415 Some("sk-oai-from-env"),
8416 );
8417 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8418
8419 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8422 assert_eq!(
8423 config
8424 .providers
8425 .anthropic
8426 .as_ref()
8427 .expect("anthropic created from env")
8428 .api_key,
8429 "sk-ant-from-env"
8430 );
8431 assert_eq!(
8432 config
8433 .providers
8434 .openai
8435 .as_ref()
8436 .expect("openai created from env")
8437 .api_key,
8438 "sk-oai-from-env"
8439 );
8440 assert!(config.providers.gemini.is_none());
8442
8443 config
8449 .save_to_dir(temp_home.path.clone())
8450 .expect("save config");
8451 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8452 .expect("read persisted config.json");
8453 assert!(
8454 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8455 "env key must not be persisted as plaintext"
8456 );
8457 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8458 assert!(
8459 disk_json["providers"]["anthropic"]
8460 .get("api_key_encrypted")
8461 .is_none(),
8462 "env-sourced anthropic key must not be re-encrypted into config.json"
8463 );
8464 assert!(
8465 disk_json["providers"]["openai"]
8466 .get("api_key_encrypted")
8467 .is_none(),
8468 "env-sourced openai key must not be re-encrypted into config.json"
8469 );
8470
8471 drop(_anthropic);
8474 drop(_openai);
8475 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
8476 assert!(reloaded
8477 .providers
8478 .anthropic
8479 .as_ref()
8480 .map(|a| a.api_key.is_empty())
8481 .unwrap_or(true));
8482 }
8483
8484 #[test]
8485 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
8486 let _lock = env_lock_acquire();
8487 let temp_home = TempHome::new();
8488 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8489 let target = temp_home.path.join("workspace-default");
8490 std::fs::create_dir_all(&target).expect("default work area dir should exist");
8491
8492 let mut config = Config::default();
8493 config.default_work_area.replace(DefaultWorkAreaConfig {
8494 path: Some("~/workspace-default".to_string()),
8495 });
8496
8497 assert_eq!(config.get_default_work_area_path(), Some(target));
8498 }
8499
8500 #[test]
8501 fn get_default_work_area_path_returns_none_for_missing_directory() {
8502 let _lock = env_lock_acquire();
8503 let temp_home = TempHome::new();
8504 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8505
8506 let mut config = Config::default();
8507 config.default_work_area.replace(DefaultWorkAreaConfig {
8508 path: Some("~/missing-default-work-area".to_string()),
8509 });
8510
8511 assert!(config.get_default_work_area_path().is_none());
8512 }
8513
8514 #[test]
8515 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
8516 let mut config = Config::default();
8517 config.tools.disabled = vec![
8518 " read_file ".to_string(),
8519 "".to_string(),
8520 "read_file".to_string(),
8521 "bash".to_string(),
8522 "default::getCurrentDir".to_string(),
8523 "default::applyPatch".to_string(),
8524 "default::custom_tool".to_string(),
8525 "mcp__alpha__inspect".to_string(),
8526 ];
8527
8528 config.normalize_tool_settings();
8529
8530 assert_eq!(
8531 config.tools.disabled,
8532 vec![
8533 "bash",
8534 "default::applyPatch",
8535 "default::custom_tool",
8536 "default::getCurrentDir",
8537 "mcp__alpha__inspect",
8538 "read_file"
8539 ]
8540 );
8541 }
8542
8543 #[test]
8544 fn config_load_preserves_disabled_references_for_catalog_resolution() {
8545 let _lock = env_lock_acquire();
8546 let temp_home = TempHome::new();
8547 temp_home.set_config_json(
8548 r#"{
8549 "tools": {
8550 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
8551 }
8552}"#,
8553 );
8554
8555 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8556 assert_eq!(
8557 config.tools.disabled,
8558 vec!["bash", "default::getCurrentDir", "read_file"]
8559 );
8560 assert!(config.disabled_tool_references().contains("bash"));
8561 assert!(config.disabled_tool_references().contains("read_file"));
8562 assert!(config
8563 .disabled_tool_references()
8564 .contains("default::getCurrentDir"));
8565 assert_eq!(
8566 config.disabled_tool_names(),
8567 BTreeSet::from([
8568 "Bash".to_string(),
8569 "GetCurrentDir".to_string(),
8570 "Read".to_string()
8571 ])
8572 );
8573 }
8574
8575 #[test]
8576 fn normalize_skill_settings_trims_dedupes_and_sorts() {
8577 let mut config = Config::default();
8578 config.skills.disabled = vec![
8579 " pdf ".to_string(),
8580 "".to_string(),
8581 "pdf".to_string(),
8582 "skill-creator".to_string(),
8583 ];
8584
8585 config.normalize_skill_settings();
8586
8587 assert_eq!(
8588 config.skills.disabled,
8589 vec!["pdf".to_string(), "skill-creator".to_string()]
8590 );
8591 }
8592
8593 #[test]
8594 fn config_load_reads_disabled_skills_as_normalized_ids() {
8595 let _lock = env_lock_acquire();
8596 let temp_home = TempHome::new();
8597 temp_home.set_config_json(
8598 r#"{
8599 "skills": {
8600 "disabled": [" pdf ", "skill-creator", "pdf", ""]
8601 }
8602}"#,
8603 );
8604
8605 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8606 assert_eq!(
8607 config.skills.disabled,
8608 vec!["pdf".to_string(), "skill-creator".to_string()]
8609 );
8610 assert!(config.disabled_skill_ids().contains("pdf"));
8611 assert!(config.disabled_skill_ids().contains("skill-creator"));
8612 }
8613
8614 #[test]
8615 fn test_server_config_defaults() {
8616 let _lock = env_lock_acquire();
8617 let temp_home = TempHome::new();
8618
8619 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8620 assert_eq!(config.server.port, 9562);
8621 assert_eq!(config.server.bind, "127.0.0.1");
8622 assert_eq!(config.server.workers, 10);
8623 assert!(config.server.static_dir.is_none());
8624 }
8625
8626 #[test]
8627 fn test_server_addr() {
8628 let mut config = Config::default();
8629 config.server.port = 9000;
8630 config.server.bind = "0.0.0.0".to_string();
8631 assert_eq!(config.server_addr(), "0.0.0.0:9000");
8632 }
8633
8634 #[test]
8635 fn test_env_var_overrides() {
8636 let _lock = env_lock_acquire();
8637 let temp_home = TempHome::new();
8638
8639 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
8640 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
8641 let _provider =
8642 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8643
8644 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8645 assert_eq!(config.server.port, 9999);
8646 assert_eq!(config.server.bind, "192.168.1.1");
8647 assert_eq!(config.provider, "openai");
8648 }
8649
8650 #[test]
8651 fn test_config_save_and_load() {
8652 let _lock = env_lock_acquire();
8653 let temp_home = TempHome::new();
8654
8655 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8656 config.server.port = 9000;
8657 config.server.bind = "0.0.0.0".to_string();
8658 config.provider = "anthropic".to_string();
8659
8660 config
8662 .save_to_dir(temp_home.path.clone())
8663 .expect("Failed to save config");
8664
8665 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8667
8668 assert_eq!(loaded.server.port, 9000);
8670 assert_eq!(loaded.server.bind, "0.0.0.0");
8671 assert_eq!(loaded.provider, "anthropic");
8672 }
8673
8674 #[test]
8675 fn modular_root_and_public_serde_preserve_legacy_shape() {
8676 let _lock = env_lock_acquire();
8677 let temp_home = TempHome::new();
8678 let input = serde_json::json!({
8679 "http_proxy": "http://proxy.example",
8680 "provider": "openai",
8681 "mcp": { "servers": [] },
8682 "future_extension": { "enabled": true },
8683 "memory": { "background_model": "memory-model" },
8684 "subagents": { "max_concurrent": 7 },
8685 "providers": { "openai": { "model": "chat-model" } }
8686 });
8687
8688 let config: Config = serde_json::from_value(input).unwrap();
8689 let public = serde_json::to_value(&config).unwrap();
8690 assert_eq!(public["http_proxy"], "http://proxy.example");
8691 assert!(public.get("mcp").is_none());
8692 assert!(public.get("mcpServers").is_some());
8693 assert_eq!(public["future_extension"]["enabled"], true);
8694 assert_eq!(public["memory"]["background_model"], "memory-model");
8695 assert_eq!(public["subagents"]["max_concurrent"], 7);
8696 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
8697
8698 let round_tripped: Config = serde_json::from_value(public).unwrap();
8699 assert_eq!(
8700 round_tripped
8701 .memory()
8702 .as_ref()
8703 .unwrap()
8704 .background_model
8705 .as_deref(),
8706 Some("memory-model")
8707 );
8708 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
8709 assert_eq!(
8710 round_tripped
8711 .providers()
8712 .openai
8713 .as_ref()
8714 .unwrap()
8715 .model
8716 .as_deref(),
8717 Some("chat-model")
8718 );
8719 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
8720
8721 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
8722 let persisted: Value =
8723 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
8724 .unwrap();
8725 assert_eq!(persisted["http_proxy"], "http://proxy.example");
8726 assert!(persisted.get("mcpServers").is_some());
8727 assert_eq!(persisted["future_extension"]["enabled"], true);
8728 assert!(persisted.get("memory").is_none());
8729 assert!(persisted.get("subagents").is_none());
8730 assert!(persisted.get("providers").is_none());
8731 for internal_section_name in [
8732 "network",
8733 "provider_routing",
8734 "model_behavior",
8735 "tooling",
8736 "workspace",
8737 "execution",
8738 "integrations",
8739 "plugin_security",
8740 ] {
8741 assert!(persisted.get(internal_section_name).is_none());
8742 }
8743 }
8744
8745 #[test]
8746 fn config_decrypts_proxy_auth_from_encrypted_field() {
8747 let _lock = env_lock_acquire();
8748 let temp_home = TempHome::new();
8749
8750 let key_guard = crate::encryption::set_test_encryption_key([
8752 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8753 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8754 0x1c, 0x1d, 0x1e, 0x1f,
8755 ]);
8756
8757 let auth = ProxyAuth {
8758 username: "user".to_string(),
8759 password: "pass".to_string(),
8760 };
8761 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
8762 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
8763
8764 temp_home.set_config_json(&format!(
8765 r#"{{
8766 "http_proxy": "http://proxy.example.com:8080",
8767 "proxy_auth_encrypted": "{encrypted}"
8768}}"#
8769 ));
8770 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8771 let loaded_auth = config
8772 .proxy_auth
8773 .as_ref()
8774 .expect("proxy auth should be hydrated");
8775 assert_eq!(loaded_auth.username, "user");
8776 assert_eq!(loaded_auth.password, "pass");
8777 drop(key_guard);
8778 }
8779
8780 #[test]
8781 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
8782 let _lock = env_lock_acquire();
8783 let temp_home = TempHome::new();
8784
8785 let key_guard = crate::encryption::set_test_encryption_key([
8787 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8788 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8789 0x1c, 0x1d, 0x1e, 0x1f,
8790 ]);
8791
8792 let auth = ProxyAuth {
8793 username: "user".to_string(),
8794 password: "pass".to_string(),
8795 };
8796 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
8797 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
8798
8799 temp_home.set_config_json(&format!(
8801 r#"{{
8802 "http_proxy": "http://proxy.example.com:8080",
8803 "http_proxy_auth_encrypted": "{encrypted}",
8804 "https_proxy_auth_encrypted": "{encrypted}"
8805}}"#
8806 ));
8807
8808 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8809 let loaded_auth = config
8810 .proxy_auth
8811 .as_ref()
8812 .expect("proxy auth should be hydrated");
8813 assert_eq!(loaded_auth.username, "user");
8814 assert_eq!(loaded_auth.password, "pass");
8815 drop(key_guard);
8816 }
8817
8818 #[test]
8819 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
8820 let _lock = env_lock_acquire();
8821 let temp_home = TempHome::new();
8822
8823 let key_guard = crate::encryption::set_test_encryption_key([
8825 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8826 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8827 0x1c, 0x1d, 0x1e, 0x1f,
8828 ]);
8829
8830 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8831 config.proxy_auth = Some(ProxyAuth {
8832 username: "user".to_string(),
8833 password: "pass".to_string(),
8834 });
8835 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
8836 assert!(error
8837 .to_string()
8838 .contains("isolated credential transaction"));
8839 let path = temp_home.path.join("config.json");
8840 assert!(
8841 !path.exists(),
8842 "a rejected unisolated secret must not create config.json"
8843 );
8844 drop(key_guard);
8845 }
8846
8847 #[test]
8848 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
8849 let _lock = env_lock_acquire();
8850 let temp_home = TempHome::new();
8851 let mut config = Config::default();
8852 config.env_vars.push(EnvVarEntry {
8853 name: "TOKEN".to_string(),
8854 value: String::new(),
8855 secret: true,
8856 value_encrypted: None,
8857 credential_ref: None,
8858 configured: true,
8859 description: None,
8860 });
8861 let path = temp_home.path.join("config.json");
8862
8863 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
8864 assert!(error
8865 .to_string()
8866 .contains("isolated credential transaction"));
8867 assert!(
8868 !path.exists(),
8869 "a rejected dangling ref must not create config.json"
8870 );
8871
8872 let original = br#"{"preserve":"original"}"#;
8873 std::fs::write(&path, original).unwrap();
8874 config.save_to_dir(temp_home.path.clone()).unwrap_err();
8875 assert_eq!(
8876 std::fs::read(&path).unwrap(),
8877 original,
8878 "a rejected dangling ref must not modify an existing config.json"
8879 );
8880 }
8881
8882 #[test]
8883 fn config_save_persists_provider_reference_and_isolates_plaintext() {
8884 let _lock = env_lock_acquire();
8885 let temp_home = TempHome::new();
8886
8887 let key_guard = crate::encryption::set_test_encryption_key([
8889 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8890 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8891 0x1c, 0x1d, 0x1e, 0x1f,
8892 ]);
8893
8894 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
8895 crate::CredentialStore::open(&temp_home.path)
8896 .replace(
8897 reference.clone(),
8898 "sk-test-provider-key",
8899 crate::CredentialSource::User,
8900 0,
8901 )
8902 .unwrap();
8903 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8904 config.provider = "openai".to_string();
8905 config.providers.openai = Some(OpenAIConfig {
8906 api_key: "sk-test-provider-key".to_string(),
8907 api_key_encrypted: None,
8908 credential_ref: Some(reference),
8909 base_url: None,
8910 model: None,
8911 fast_model: None,
8912 vision_model: None,
8913 reasoning_effort: None,
8914 responses_only_models: vec![],
8915 request_overrides: None,
8916 extra: Default::default(),
8917 api_key_from_env: false,
8918 });
8919
8920 config
8921 .save_to_dir(temp_home.path.clone())
8922 .expect("save should persist provider reference");
8923
8924 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
8925 .expect("read providers.json");
8926 assert!(
8927 !content.contains("api_key_encrypted"),
8928 "providers.json must not store provider ciphertext"
8929 );
8930 assert!(
8931 !content.contains("\"api_key\""),
8932 "providers.json should not store plaintext provider keys"
8933 );
8934
8935 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8936 let openai = loaded
8937 .providers
8938 .openai
8939 .as_ref()
8940 .expect("openai config should be present");
8941 assert_eq!(openai.api_key, "sk-test-provider-key");
8942
8943 drop(key_guard);
8944 }
8945
8946 #[test]
8947 fn config_save_persists_mcp_servers_in_mainstream_format() {
8948 let _lock = env_lock_acquire();
8949 let temp_home = TempHome::new();
8950
8951 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8952
8953 let mut env = std::collections::HashMap::new();
8954 env.insert("TOKEN".to_string(), "supersecret".to_string());
8955
8956 config.mcp.servers = vec![
8957 bamboo_domain::mcp_config::McpServerConfig {
8958 id: "stdio-secret".to_string(),
8959 name: None,
8960 enabled: true,
8961 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
8962 bamboo_domain::mcp_config::StdioConfig {
8963 command: "echo".to_string(),
8964 args: vec![],
8965 cwd: None,
8966 env,
8967 env_encrypted: std::collections::HashMap::new(),
8968 env_credential_refs: std::collections::HashMap::new(),
8969 startup_timeout_ms: 5000,
8970 },
8971 ),
8972 request_timeout_ms: 5000,
8973 healthcheck_interval_ms: 1000,
8974 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
8975 allowed_tools: vec![],
8976 denied_tools: vec![],
8977 },
8978 bamboo_domain::mcp_config::McpServerConfig {
8979 id: "sse-secret".to_string(),
8980 name: None,
8981 enabled: true,
8982 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
8983 bamboo_domain::mcp_config::SseConfig {
8984 url: "http://localhost:8080/sse".to_string(),
8985 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
8986 name: "Authorization".to_string(),
8987 value: "Bearer token123".to_string(),
8988 value_encrypted: None,
8989 credential_ref: None,
8990 }],
8991 connect_timeout_ms: 5000,
8992 },
8993 ),
8994 request_timeout_ms: 5000,
8995 healthcheck_interval_ms: 1000,
8996 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
8997 allowed_tools: vec![],
8998 denied_tools: vec![],
8999 },
9000 ];
9001
9002 config
9003 .save_to_dir(temp_home.path.clone())
9004 .expect("save should persist MCP servers");
9005
9006 let content =
9007 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9008 assert!(
9009 content.contains("\"mcpServers\""),
9010 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9011 );
9012 assert!(
9013 content.contains("supersecret"),
9014 "config.json should persist MCP stdio env in mainstream format"
9015 );
9016 assert!(
9017 content.contains("Bearer token123"),
9018 "config.json should persist MCP SSE headers in mainstream format"
9019 );
9020 assert!(
9021 !content.contains("\"env_encrypted\""),
9022 "config.json should not persist legacy env_encrypted fields"
9023 );
9024 assert!(
9025 !content.contains("\"value_encrypted\""),
9026 "config.json should not persist legacy value_encrypted fields"
9027 );
9028
9029 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9030 let stdio = loaded
9031 .mcp
9032 .servers
9033 .iter()
9034 .find(|s| s.id == "stdio-secret")
9035 .expect("stdio server should exist");
9036 match &stdio.transport {
9037 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9038 assert_eq!(
9039 stdio.env.get("TOKEN").map(|s| s.as_str()),
9040 Some("supersecret")
9041 );
9042 }
9043 _ => panic!("Expected stdio transport"),
9044 }
9045
9046 let sse = loaded
9047 .mcp
9048 .servers
9049 .iter()
9050 .find(|s| s.id == "sse-secret")
9051 .expect("sse server should exist");
9052 match &sse.transport {
9053 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
9054 assert_eq!(sse.headers[0].value, "Bearer token123");
9055 }
9056 _ => panic!("Expected SSE transport"),
9057 }
9058 }
9059
9060 #[test]
9061 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9062 let _lock = env_lock_acquire();
9063 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9064 let temp_home = TempHome::new();
9065 let env_plaintext = "mcp-root-env-plaintext-597";
9066 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9067 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9068 std::fs::write(
9069 temp_home.path.join("config.json"),
9070 serde_json::to_vec_pretty(&serde_json::json!({
9071 "features": {"provider_model_ref": true},
9072 "mcpServers": {
9073 "stdio-root": {
9074 "command": "unused-disabled-command",
9075 "env": {"TOKEN": env_plaintext},
9076 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9077 "env_credential_refs": {
9078 "TOKEN": "mcp.stdio-root.env_TOKEN"
9079 }
9080 },
9081 "http-root": {
9082 "url": "https://example.test/mcp",
9083 "transport_kind": "streamable_http",
9084 "headers": {"Authorization": header_plaintext},
9085 "header_credential_refs": {
9086 "Authorization": "mcp.http-root.header_Authorization"
9087 }
9088 }
9089 }
9090 }))
9091 .unwrap(),
9092 )
9093 .unwrap();
9094 std::fs::write(
9095 temp_home.path.join("mcp.json"),
9096 serde_json::to_vec_pretty(&serde_json::json!({
9097 "schema_version": 1,
9098 "revision": 7,
9099 "data": {
9100 "stdio-root": {
9101 "command": "unused-disabled-command",
9102 "enabled": false,
9103 "env_encrypted": {"TOKEN": legacy_ciphertext},
9104 "request_timeout_ms": 100,
9105 "healthcheck_interval_ms": 100
9106 },
9107 "http-root": {
9108 "url": "https://example.test/mcp",
9109 "transport_kind": "streamable_http",
9110 "enabled": false,
9111 "headers": {"Authorization": header_plaintext},
9112 "request_timeout_ms": 100,
9113 "healthcheck_interval_ms": 100
9114 }
9115 }
9116 }))
9117 .unwrap(),
9118 )
9119 .unwrap();
9120
9121 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9122 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9123 temp_home.path.join("mcp.json"),
9124 1,
9125 )
9126 .load()
9127 .unwrap()
9128 .unwrap();
9129 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9130 config.mcp = stored.data;
9131 config
9132 .hydrate_mcp_credentials_from_store(&temp_home.path)
9133 .unwrap();
9134
9135 let public = serde_json::to_value(&config.mcp).unwrap();
9136 let rendered_public = public.to_string();
9137 assert!(rendered_public.contains(env_plaintext));
9138 assert!(rendered_public.contains(header_plaintext));
9139 let compatible: bamboo_domain::mcp_config::McpConfig =
9140 serde_json::from_value(public).unwrap();
9141 assert_eq!(compatible.servers.len(), 2);
9142
9143 config.features.provider_model_ref = !config.features.provider_model_ref;
9144 config.save_to_dir(temp_home.path.clone()).unwrap();
9145 config.features.provider_model_ref = !config.features.provider_model_ref;
9146 config.save_to_dir(temp_home.path.clone()).unwrap();
9147
9148 for entry in std::fs::read_dir(&temp_home.path)
9149 .unwrap()
9150 .filter_map(Result::ok)
9151 {
9152 let name = entry.file_name().to_string_lossy().to_string();
9153 if name == "credentials.json" || entry.path().is_dir() {
9154 continue;
9155 }
9156 if name == "config.json"
9157 || name == "mcp.json"
9158 || name.contains(".bak")
9159 || name.starts_with("config-credential-migration")
9160 {
9161 let bytes = std::fs::read(entry.path()).unwrap();
9162 let content = String::from_utf8_lossy(&bytes);
9163 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9164 assert!(
9165 !content.contains(header_plaintext),
9166 "secret leaked to {name}"
9167 );
9168 assert!(
9169 !content.contains(&legacy_ciphertext),
9170 "legacy ciphertext leaked to {name}"
9171 );
9172 }
9173 }
9174 }
9175
9176 #[test]
9179 fn env_vars_as_map_includes_only_non_empty_values() {
9180 let mut config = Config::default();
9181 config.env_vars.extend([
9182 EnvVarEntry {
9183 name: "A".to_string(),
9184 value: "val_a".to_string(),
9185 secret: false,
9186 value_encrypted: None,
9187 credential_ref: None,
9188 configured: true,
9189 description: None,
9190 },
9191 EnvVarEntry {
9192 name: "B".to_string(),
9193 value: "".to_string(), secret: true,
9195 value_encrypted: None,
9196 credential_ref: None,
9197 configured: false,
9198 description: None,
9199 },
9200 EnvVarEntry {
9201 name: "C".to_string(),
9202 value: " ".to_string(), secret: false,
9204 value_encrypted: None,
9205 credential_ref: None,
9206 configured: true,
9207 description: None,
9208 },
9209 EnvVarEntry {
9210 name: "D".to_string(),
9211 value: "val_d".to_string(),
9212 secret: true,
9213 value_encrypted: Some("enc".to_string()),
9214 credential_ref: None,
9215 configured: true,
9216 description: Some("desc".to_string()),
9217 },
9218 ]);
9219
9220 let map = config.env_vars_as_map();
9221 assert_eq!(map.len(), 2);
9222 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9223 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9224 assert!(!map.contains_key("B"));
9225 assert!(!map.contains_key("C"));
9226 }
9227
9228 #[test]
9229 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9230 let mut config = Config::default();
9231 config.env_vars.extend([
9232 EnvVarEntry {
9233 name: "PLAIN".to_string(),
9234 value: "visible".to_string(),
9235 secret: false,
9236 value_encrypted: None,
9237 credential_ref: None,
9238 configured: true,
9239 description: None,
9240 },
9241 EnvVarEntry {
9242 name: "SECRET".to_string(),
9243 value: "hidden_value".to_string(),
9244 secret: true,
9245 value_encrypted: Some("enc_data".to_string()),
9246 credential_ref: None,
9247 configured: true,
9248 description: None,
9249 },
9250 ]);
9251
9252 config.sanitize_env_vars_for_disk();
9253
9254 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
9257
9258 #[test]
9259 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9260 let mut config = Config::default();
9261 config.env_vars.extend([
9262 EnvVarEntry {
9263 name: "OPEN".to_string(),
9264 value: "val".to_string(),
9265 secret: false,
9266 value_encrypted: None,
9267 credential_ref: None,
9268 configured: true,
9269 description: None,
9270 },
9271 EnvVarEntry {
9272 name: "HIDDEN".to_string(),
9273 value: "real_secret".to_string(),
9274 secret: true,
9275 value_encrypted: Some("enc".to_string()),
9276 credential_ref: None,
9277 configured: true,
9278 description: None,
9279 },
9280 ]);
9281
9282 config.sanitize_env_vars_for_disk();
9283
9284 assert_eq!(config.env_vars[0].value, "val");
9286 assert_eq!(config.env_vars[1].value, "");
9288 assert!(config.env_vars[1].value_encrypted.is_none());
9289 }
9290
9291 #[test]
9292 fn legacy_env_ciphertext_is_read_but_never_serialized() {
9293 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9294 let mut config: Config = serde_json::from_value(serde_json::json!({
9295 "env_vars": [{
9296 "name": "TOKEN",
9297 "secret": true,
9298 "value_encrypted": ciphertext
9299 }]
9300 }))
9301 .unwrap();
9302 config.hydrate_env_vars_from_encrypted();
9303 assert_eq!(config.env_vars[0].value, "my-secret-token");
9304 let serialized = serde_json::to_value(&config).unwrap();
9305 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9306 }
9307
9308 #[test]
9309 fn configured_env_ref_missing_from_store_fails_closed() {
9310 let dir = tempfile::tempdir().unwrap();
9311 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9312 let mut config = Config::default();
9313 config.env_vars.push(EnvVarEntry {
9314 name: "TOKEN".to_string(),
9315 value: String::new(),
9316 secret: true,
9317 value_encrypted: None,
9318 credential_ref: Some(reference),
9319 configured: true,
9320 description: None,
9321 });
9322 let error = config
9323 .hydrate_env_var_credentials_from_store(dir.path())
9324 .unwrap_err();
9325 assert!(error
9326 .to_string()
9327 .contains("referenced env credential is unavailable"));
9328 assert!(config.env_vars[0].configured);
9329 assert!(config.env_vars[0].value.is_empty());
9330 }
9331
9332 #[test]
9333 fn configured_notification_ref_missing_from_store_fails_closed() {
9334 let dir = tempfile::tempdir().unwrap();
9335 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9336 let mut config = Config::default();
9337 config.notifications.ntfy.credential_ref = Some(reference);
9338 config.notifications.ntfy.configured = true;
9339
9340 let error = config
9341 .hydrate_notification_credentials_from_store(dir.path())
9342 .unwrap_err();
9343
9344 assert!(error
9345 .to_string()
9346 .contains("referenced ntfy credential is unavailable"));
9347 assert!(config.notifications.ntfy.configured);
9348 assert!(config.notifications.ntfy.token.is_none());
9349 }
9350
9351 #[test]
9352 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9353 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9354 let dir = tempfile::tempdir().unwrap();
9355 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9356 crate::CredentialStore::open(dir.path())
9357 .replace(
9358 reference.clone(),
9359 "exclusive-notification-secret",
9360 crate::CredentialSource::User,
9361 0,
9362 )
9363 .unwrap();
9364
9365 let mut config = Config::default();
9366 config.notifications.ntfy.credential_ref = Some(reference.clone());
9367 config.notifications.ntfy.configured = true;
9368 config.proxy_auth_credential_ref = Some(reference.clone());
9369 let error = config
9370 .hydrate_notification_credentials_from_store(dir.path())
9371 .unwrap_err();
9372 let rendered = error.to_string();
9373 assert!(rendered
9374 .contains("notification credential reference is shared by another config consumer"));
9375 assert!(!rendered.contains(reference.as_str()));
9376 assert!(config.notifications.ntfy.token.is_none());
9377
9378 config.proxy_auth_credential_ref = None;
9379 config
9380 .hydrate_notification_credentials_from_store(dir.path())
9381 .unwrap();
9382 assert_eq!(
9383 config.notifications.ntfy.token.as_deref(),
9384 Some("exclusive-notification-secret")
9385 );
9386 }
9387
9388 #[test]
9389 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9390 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9391 let dir = tempfile::tempdir().unwrap();
9392 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9393 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9394 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9395 "notifications": {
9396 "ntfy": { "enabled": true, "token_encrypted": ntfy },
9397 "bark": { "enabled": true, "device_key_encrypted": bark }
9398 }
9399 }))
9400 .unwrap();
9401 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9402 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9403
9404 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9405
9406 assert!(loaded.notifications.ntfy.token.is_none());
9407 assert!(loaded.notifications.bark.device_key.is_none());
9408 assert_eq!(
9409 std::fs::read(dir.path().join("config.json")).unwrap(),
9410 bytes
9411 );
9412 }
9413
9414 #[test]
9415 fn publish_and_current_env_vars_round_trip() {
9416 let _lock = crate::test_support::env_cache_lock_acquire();
9426 let mut config = Config::default();
9427 config.env_vars.extend([EnvVarEntry {
9428 name: "TEST_PUBLISH".to_string(),
9429 value: "pub_value".to_string(),
9430 secret: false,
9431 value_encrypted: None,
9432 credential_ref: None,
9433 configured: true,
9434 description: None,
9435 }]);
9436
9437 config.publish_env_vars();
9438 assert_eq!(
9439 Config::current_env_vars()
9440 .get("TEST_PUBLISH")
9441 .map(String::as_str),
9442 Some("pub_value")
9443 );
9444 }
9445
9446 #[test]
9447 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9448 let mut config = Config::default();
9449 config.subagents.broker = Some(BrokerClientConfig {
9450 endpoint: "ws://127.0.0.1:9600".to_string(),
9451 token: "super-secret-token".to_string(),
9452 token_encrypted: None,
9453 credential_ref: None,
9454 configured: false,
9455 });
9456
9457 config.refresh_broker_token_encrypted().unwrap();
9459 config.sanitize_broker_token_for_disk();
9460 let broker = config.subagents.broker.as_ref().unwrap();
9461 assert!(broker.token.is_empty(), "plaintext cleared for disk");
9462 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9463 assert_ne!(
9464 broker.token_encrypted.as_deref(),
9465 Some("super-secret-token")
9466 );
9467
9468 config.hydrate_broker_token_from_encrypted();
9470 assert_eq!(
9471 config.subagents.broker.as_ref().unwrap().token,
9472 "super-secret-token"
9473 );
9474 }
9475
9476 #[test]
9477 fn broker_token_empty_refresh_preserves_ciphertext() {
9478 let mut config = Config::default();
9480 config.subagents.broker = Some(BrokerClientConfig {
9481 endpoint: "ws://h:9600".to_string(),
9482 token: String::new(),
9483 token_encrypted: Some("existing-cipher".to_string()),
9484 credential_ref: None,
9485 configured: false,
9486 });
9487 config.refresh_broker_token_encrypted().unwrap();
9488 assert_eq!(
9489 config
9490 .subagents
9491 .broker
9492 .as_ref()
9493 .unwrap()
9494 .token_encrypted
9495 .as_deref(),
9496 Some("existing-cipher"),
9497 );
9498 }
9499
9500 #[test]
9501 fn notifications_config_defaults_when_key_missing() {
9502 let config: Config = serde_json::from_str("{}").expect("empty object parses");
9505 assert_eq!(config.notifications, NotificationsConfig::default());
9506 assert_eq!(config.notifications.desktop.enabled, None);
9507 assert!(!config.notifications.ntfy.enabled);
9508 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
9509 assert_eq!(config.notifications.ntfy.token, None);
9510 assert!(!config.notifications.bark.enabled);
9511 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
9512 assert_eq!(config.notifications.bark.device_key, None);
9513 }
9514
9515 #[test]
9516 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
9517 let mut config = Config::default();
9518 config.notifications.ntfy = NtfyChannelConfig {
9519 enabled: true,
9520 base_url: "https://ntfy.sh".to_string(),
9521 topic: "bamboo-alerts".to_string(),
9522 token: Some("tk_super_secret".to_string()),
9523 token_encrypted: None,
9524 credential_ref: None,
9525 configured: false,
9526 };
9527
9528 config.refresh_notifications_encrypted().unwrap();
9531 assert!(config.notifications.ntfy.token_encrypted.is_some());
9532 assert_ne!(
9533 config.notifications.ntfy.token_encrypted.as_deref(),
9534 Some("tk_super_secret")
9535 );
9536
9537 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
9539 assert!(
9540 !json.contains("tk_super_secret"),
9541 "plaintext token must never be serialized"
9542 );
9543 assert!(!json.contains("token_encrypted"));
9544
9545 config.notifications.ntfy.token = None;
9547 config.hydrate_notifications_from_encrypted();
9548 assert_eq!(
9549 config.notifications.ntfy.token.as_deref(),
9550 Some("tk_super_secret")
9551 );
9552 }
9553
9554 #[test]
9555 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
9556 let mut config = Config::default();
9557 config.notifications.bark = BarkChannelConfig {
9558 enabled: true,
9559 base_url: "https://api.day.app".to_string(),
9560 device_key: Some("dk_super_secret".to_string()),
9561 device_key_encrypted: None,
9562 credential_ref: None,
9563 configured: false,
9564 };
9565
9566 config.refresh_notifications_encrypted().unwrap();
9567 assert!(config.notifications.bark.device_key_encrypted.is_some());
9568 assert_ne!(
9569 config.notifications.bark.device_key_encrypted.as_deref(),
9570 Some("dk_super_secret")
9571 );
9572
9573 let json = serde_json::to_string(&config.notifications.bark).unwrap();
9574 assert!(
9575 !json.contains("dk_super_secret"),
9576 "plaintext device key must never be serialized"
9577 );
9578 assert!(!json.contains("device_key_encrypted"));
9579
9580 config.notifications.bark.device_key = None;
9581 config.hydrate_notifications_from_encrypted();
9582 assert_eq!(
9583 config.notifications.bark.device_key.as_deref(),
9584 Some("dk_super_secret")
9585 );
9586 }
9587
9588 #[test]
9589 fn notification_secrets_empty_refresh_preserves_ciphertext() {
9590 let mut config = Config::default();
9593 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
9594 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
9595
9596 config.refresh_notifications_encrypted().unwrap();
9597
9598 assert_eq!(
9599 config.notifications.ntfy.token_encrypted.as_deref(),
9600 Some("existing-ntfy-cipher")
9601 );
9602 assert_eq!(
9603 config.notifications.bark.device_key_encrypted.as_deref(),
9604 Some("existing-bark-cipher")
9605 );
9606 }
9607
9608 #[test]
9609 fn hydrate_skips_non_secret_entries() {
9610 let mut config = Config::default();
9611 config.env_vars.extend([EnvVarEntry {
9612 name: "PLAIN".to_string(),
9613 value: "original".to_string(),
9614 secret: false,
9615 value_encrypted: Some("should-be-ignored".to_string()),
9616 credential_ref: None,
9617 configured: true,
9618 description: None,
9619 }]);
9620
9621 config.hydrate_env_vars_from_encrypted();
9622 assert_eq!(config.env_vars[0].value, "original");
9624 }
9625
9626 #[test]
9627 fn default_config_has_empty_env_vars() {
9628 assert!(Config::default().env_vars.is_empty());
9633 }
9634
9635 #[test]
9636 fn serde_round_trip_with_env_vars() {
9637 let mut config = Config::default();
9638 config.env_vars.extend([
9639 EnvVarEntry {
9640 name: "KEY1".to_string(),
9641 value: "val1".to_string(),
9642 secret: false,
9643 value_encrypted: None,
9644 credential_ref: None,
9645 configured: true,
9646 description: Some("First key".to_string()),
9647 },
9648 EnvVarEntry {
9649 name: "KEY2".to_string(),
9650 value: "".to_string(), secret: true,
9652 value_encrypted: Some("enc123".to_string()),
9653 credential_ref: None,
9654 configured: true,
9655 description: None,
9656 },
9657 ]);
9658
9659 let json = serde_json::to_string(&config).unwrap();
9660 let restored: Config = serde_json::from_str(&json).unwrap();
9661
9662 assert_eq!(restored.env_vars.len(), 2);
9663 assert_eq!(restored.env_vars[0].name, "KEY1");
9664 assert_eq!(restored.env_vars[0].value, "val1");
9665 assert!(!restored.env_vars[0].secret);
9666 assert_eq!(restored.env_vars[1].name, "KEY2");
9667 assert!(restored.env_vars[1].secret);
9668 assert!(restored.env_vars[1].value_encrypted.is_none());
9669 }
9670
9671 #[test]
9674 #[allow(clippy::field_reassign_with_default)]
9676 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
9677 let mut config = Config::default();
9678 config.provider = "openai".to_string();
9679 config.providers.openai = Some(OpenAIConfig {
9680 api_key: "test".to_string(),
9681 api_key_encrypted: None,
9682 credential_ref: None,
9683 base_url: None,
9684 model: Some("legacy-gpt-4o".to_string()),
9685 fast_model: None,
9686 vision_model: None,
9687 reasoning_effort: None,
9688 responses_only_models: vec![],
9689 request_overrides: None,
9690 extra: Default::default(),
9691 api_key_from_env: false,
9692 });
9693 config.features.provider_model_ref = true;
9694 config.defaults = Some(DefaultsConfig {
9695 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9696 fast: None,
9697 task_summary: None,
9698 vision: None,
9699 memory_background: None,
9700 planning: None,
9701 search: None,
9702 code_review: None,
9703 sub_agent: None,
9704 subagent_models: Default::default(),
9705 });
9706
9707 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
9708 }
9709
9710 #[test]
9711 #[allow(clippy::field_reassign_with_default)]
9713 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
9714 let mut config = Config::default();
9715 config.provider = "openai".to_string();
9716 config.providers.openai = Some(OpenAIConfig {
9717 api_key: "test".to_string(),
9718 api_key_encrypted: None,
9719 credential_ref: None,
9720 base_url: None,
9721 model: Some("legacy-gpt-4o".to_string()),
9722 fast_model: None,
9723 vision_model: None,
9724 reasoning_effort: None,
9725 responses_only_models: vec![],
9726 request_overrides: None,
9727 extra: Default::default(),
9728 api_key_from_env: false,
9729 });
9730 config.features.provider_model_ref = false;
9731 config.defaults = Some(DefaultsConfig {
9732 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9733 fast: None,
9734 task_summary: None,
9735 vision: None,
9736 memory_background: None,
9737 planning: None,
9738 search: None,
9739 code_review: None,
9740 sub_agent: None,
9741 subagent_models: Default::default(),
9742 });
9743
9744 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
9745 }
9746
9747 #[test]
9748 #[allow(clippy::field_reassign_with_default)]
9750 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
9751 let mut config = Config::default();
9752 config.provider = "openai".to_string();
9753 config.providers.openai = Some(OpenAIConfig {
9754 api_key: "test".to_string(),
9755 api_key_encrypted: None,
9756 credential_ref: None,
9757 base_url: None,
9758 model: Some("gpt-4o".to_string()),
9759 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9760 vision_model: None,
9761 reasoning_effort: None,
9762 responses_only_models: vec![],
9763 request_overrides: None,
9764 extra: Default::default(),
9765 api_key_from_env: false,
9766 });
9767 config.features.provider_model_ref = true;
9768 config.defaults = Some(DefaultsConfig {
9769 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9770 fast: Some(bamboo_domain::ProviderModelRef::new(
9771 "anthropic",
9772 "claude-3-5-haiku",
9773 )),
9774 task_summary: None,
9775 vision: None,
9776 memory_background: None,
9777 planning: None,
9778 search: None,
9779 code_review: None,
9780 sub_agent: None,
9781 subagent_models: Default::default(),
9782 });
9783
9784 assert_eq!(
9785 config.get_fast_model(),
9786 Some("claude-3-5-haiku".to_string())
9787 );
9788 }
9789
9790 #[test]
9791 #[allow(clippy::field_reassign_with_default)]
9793 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
9794 let mut config = Config::default();
9795 config.provider = "openai".to_string();
9796 config.providers.openai = Some(OpenAIConfig {
9797 api_key: "test".to_string(),
9798 api_key_encrypted: None,
9799 credential_ref: None,
9800 base_url: None,
9801 model: Some("gpt-4o".to_string()),
9802 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9803 vision_model: None,
9804 reasoning_effort: None,
9805 responses_only_models: vec![],
9806 request_overrides: None,
9807 extra: Default::default(),
9808 api_key_from_env: false,
9809 });
9810 config.features.provider_model_ref = false;
9811 config.defaults = Some(DefaultsConfig {
9812 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9813 fast: Some(bamboo_domain::ProviderModelRef::new(
9814 "anthropic",
9815 "claude-3-5-haiku",
9816 )),
9817 task_summary: None,
9818 vision: None,
9819 memory_background: None,
9820 planning: None,
9821 search: None,
9822 code_review: None,
9823 sub_agent: None,
9824 subagent_models: Default::default(),
9825 });
9826
9827 assert_eq!(
9828 config.get_fast_model(),
9829 Some("legacy-gpt-4o-mini".to_string())
9830 );
9831 }
9832
9833 #[test]
9834 #[allow(clippy::field_reassign_with_default)]
9836 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
9837 let mut config = Config::default();
9838 config.provider = "openai".to_string();
9839 config.features.provider_model_ref = true;
9840 config.defaults = Some(DefaultsConfig {
9841 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9842 fast: None,
9843 task_summary: None,
9844 vision: None,
9845 memory_background: None,
9846 planning: None,
9847 search: None,
9848 code_review: None,
9849 sub_agent: None,
9850 subagent_models: Default::default(),
9851 });
9852
9853 assert_eq!(
9854 config.get_fast_model(),
9855 Some("claude-3-7-sonnet".to_string())
9856 );
9857 }
9858
9859 #[test]
9860 #[allow(clippy::field_reassign_with_default)]
9862 fn get_memory_background_model_prefers_defaults_memory_background() {
9863 let mut config = Config::default();
9864 config.provider = "openai".to_string();
9865 config.providers.openai = Some(OpenAIConfig {
9866 api_key: "test".to_string(),
9867 api_key_encrypted: None,
9868 credential_ref: None,
9869 base_url: None,
9870 model: Some("gpt-4o".to_string()),
9871 fast_model: Some("gpt-4o-mini".to_string()),
9872 vision_model: None,
9873 reasoning_effort: None,
9874 responses_only_models: vec![],
9875 request_overrides: None,
9876 extra: Default::default(),
9877 api_key_from_env: false,
9878 });
9879 config.features.provider_model_ref = true;
9880 config.defaults = Some(DefaultsConfig {
9881 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9882 fast: Some(bamboo_domain::ProviderModelRef::new(
9883 "openai",
9884 "gpt-4o-mini",
9885 )),
9886 task_summary: None,
9887 vision: None,
9888 memory_background: Some(bamboo_domain::ProviderModelRef::new(
9889 "anthropic",
9890 "claude-3-5-haiku",
9891 )),
9892 planning: None,
9893 search: None,
9894 code_review: None,
9895 sub_agent: None,
9896 subagent_models: Default::default(),
9897 });
9898
9899 assert_eq!(
9900 config.get_memory_background_model(),
9901 Some("claude-3-5-haiku".to_string())
9902 );
9903 }
9904
9905 #[test]
9906 #[allow(clippy::field_reassign_with_default)]
9908 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
9909 let mut config = Config::default();
9910 config.provider = "openai".to_string();
9911 config.features.provider_model_ref = true;
9912 config.defaults = Some(DefaultsConfig {
9913 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9914 fast: Some(bamboo_domain::ProviderModelRef::new(
9915 "anthropic",
9916 "claude-3-5-haiku",
9917 )),
9918 task_summary: None,
9919 vision: None,
9920 memory_background: None,
9921 planning: None,
9922 search: None,
9923 code_review: None,
9924 sub_agent: None,
9925 subagent_models: Default::default(),
9926 });
9927
9928 assert_eq!(
9929 config.get_memory_background_model(),
9930 Some("claude-3-5-haiku".to_string())
9931 );
9932 }
9933
9934 #[test]
9935 #[allow(clippy::field_reassign_with_default)]
9937 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
9938 let mut config = Config::default();
9939 config.provider = "openai".to_string();
9940 config.providers.openai = Some(OpenAIConfig {
9941 api_key: "test".to_string(),
9942 api_key_encrypted: None,
9943 credential_ref: None,
9944 base_url: None,
9945 model: Some("gpt-4o".to_string()),
9946 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9947 vision_model: None,
9948 reasoning_effort: None,
9949 responses_only_models: vec![],
9950 request_overrides: None,
9951 extra: Default::default(),
9952 api_key_from_env: false,
9953 });
9954 config.features.provider_model_ref = false;
9955 config.defaults = Some(DefaultsConfig {
9956 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9957 fast: Some(bamboo_domain::ProviderModelRef::new(
9958 "anthropic",
9959 "claude-3-5-haiku",
9960 )),
9961 task_summary: None,
9962 vision: None,
9963 memory_background: Some(bamboo_domain::ProviderModelRef::new(
9964 "anthropic",
9965 "claude-3-5-haiku",
9966 )),
9967 planning: None,
9968 search: None,
9969 code_review: None,
9970 sub_agent: None,
9971 subagent_models: Default::default(),
9972 });
9973
9974 assert_eq!(
9975 config.get_memory_background_model(),
9976 Some("legacy-gpt-4o-mini".to_string())
9977 );
9978 }
9979
9980 #[test]
9987 fn is_host_trusted_requires_https_scheme() {
9988 let hosts = vec!["github.com/bigduu/".to_string()];
9989 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
9990 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9991 }
9992
9993 #[test]
9994 fn is_host_trusted_is_case_insensitive_on_both_sides() {
9995 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
9997 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9998 let hosts = vec!["github.com/bigduu/".to_string()];
10000 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10001 }
10002
10003 #[test]
10004 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10005 let hosts = vec!["trusted.example.com".to_string()];
10006 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10007 assert!(!is_host_trusted(
10011 "https://trusted.example.com.evil.com/x",
10012 &hosts
10013 ));
10014 assert!(!is_host_trusted(
10015 "https://trusted.example.comevil.com/x",
10016 &hosts
10017 ));
10018 }
10019
10020 #[test]
10021 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10022 let hosts = vec!["github.com/bigduu/".to_string()];
10024 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10025 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10026 }
10027
10028 #[test]
10029 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10030 let hosts = vec!["example.com".to_string()];
10031 assert!(is_host_trusted("https://example.com/", &hosts));
10032 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10033 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10036 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10037 }
10038
10039 #[test]
10040 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10041 let hosts = vec!["github.com/bigduu/".to_string()];
10042 assert!(is_host_trusted(
10044 "https://someuser@github.com/bigduu/x",
10045 &hosts
10046 ));
10047 assert!(!is_host_trusted(
10050 "https://github.com@evil.com/bigduu/",
10051 &hosts
10052 ));
10053 }
10054
10055 #[test]
10056 fn is_host_trusted_ignores_an_explicit_port() {
10057 let hosts = vec!["github.com/bigduu/".to_string()];
10058 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10059 }
10060
10061 #[test]
10062 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10063 let hosts = vec!["github.com/bigduu/".to_string()];
10064 assert!(!is_host_trusted("not a url at all", &hosts));
10065 assert!(!is_host_trusted("", &hosts));
10066 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10068
10069 #[test]
10070 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10071 let hosts = vec!["github.com/bigduu/".to_string()];
10072 assert!(!is_host_trusted(
10075 "https://github.com/bigduu/../evil/x",
10076 &hosts
10077 ));
10078 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10080 }
10081
10082 #[test]
10083 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10084 let mut config = Config::default();
10085 config.plugin_trust.trusted_hosts = vec![
10086 " GitHub.com/BigDuu/ ".to_string(),
10087 "".to_string(),
10088 " ".to_string(),
10089 "Example.COM".to_string(),
10090 ];
10091 config.normalize_plugin_trust_settings();
10092 assert_eq!(
10093 config.plugin_trust.trusted_hosts,
10094 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10095 );
10096 }
10097
10098 #[test]
10104 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10105 let config = Config::default();
10107 assert_eq!(
10108 config.plugin_trust.enforcement,
10109 PluginTrustEnforcement::Strict
10110 );
10111 assert!(!config.plugin_trust.enforcement_is_off());
10112
10113 let json = serde_json::json!({
10119 "trusted_hosts": ["example.com"],
10120 "trusted_keys": [],
10121 });
10122 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10123 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10124 }
10125
10126 #[test]
10127 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10128 for raw in ["off", "OFF", "Off", " off "] {
10129 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10130 "enforcement": raw,
10131 }))
10132 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10133 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10134 assert!(trust.enforcement_is_off());
10135 }
10136 for raw in ["strict", "STRICT", " Strict "] {
10137 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10138 "enforcement": raw,
10139 }))
10140 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10141 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10142 }
10143
10144 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10145 "enforcement": "nonsense",
10146 }))
10147 .expect_err("an unrecognized string must be rejected, not silently default");
10148 assert!(err.to_string().contains("nonsense"));
10149 }
10150
10151 #[test]
10152 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10153 let trust: PluginTrustConfig =
10156 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10157 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10158
10159 let trust: PluginTrustConfig =
10160 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10161 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10162 }
10163
10164 #[test]
10165 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10166 let trust = PluginTrustConfig {
10171 enforcement: PluginTrustEnforcement::Off,
10172 ..PluginTrustConfig::default()
10173 };
10174 let json = serde_json::to_value(&trust).unwrap();
10175 assert_eq!(json["enforcement"], "off");
10176
10177 let trust = PluginTrustConfig {
10178 enforcement: PluginTrustEnforcement::Strict,
10179 ..PluginTrustConfig::default()
10180 };
10181 let json = serde_json::to_value(&trust).unwrap();
10182 assert_eq!(json["enforcement"], "strict");
10183 }
10184
10185 #[test]
10186 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10187 let mut config = Config::default();
10190 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10191 config.normalize_plugin_trust_settings();
10192 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10193 }
10194
10195 #[test]
10196 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10197 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10202 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10203 )));
10204 assert_eq!(
10205 config.plugin_trust.enforcement,
10206 PluginTrustEnforcement::Strict
10207 );
10208
10209 let outcome = crate::dot_path::apply_dot_path_set(
10210 &config,
10211 "plugin_trust.enforcement",
10212 crate::dot_path::parse_cli_value("off"),
10213 )
10214 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10215 assert_eq!(
10216 outcome.config.plugin_trust.enforcement,
10217 PluginTrustEnforcement::Off
10218 );
10219
10220 let outcome = crate::dot_path::apply_dot_path_set(
10222 &outcome.config,
10223 "plugin_trust.enforcement",
10224 crate::dot_path::parse_cli_value("strict"),
10225 )
10226 .expect("setting it back to strict should also round-trip");
10227 assert_eq!(
10228 outcome.config.plugin_trust.enforcement,
10229 PluginTrustEnforcement::Strict
10230 );
10231 }
10232}