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::PathBuf;
65use std::sync::{OnceLock, RwLock};
66
67use crate::keyword_masking::KeywordMaskingConfig;
68use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
69use bamboo_domain::tool_names::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")]
703 pub max_concurrent: Option<usize>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub worker_bin: Option<String>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub worker_args: Option<Vec<String>>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub fabric_dir: Option<String>,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub executor: Option<String>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub claude_code_binary: Option<String>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub claude_code_model: Option<String>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub claude_code_permission_mode: Option<String>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub claude_code_inherit_user_config: Option<bool>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub claude_code_forward_env: Option<Vec<String>>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub codex_binary: Option<String>,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub codex_model: Option<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub codex_mode: Option<CodexMode>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub codex_auth_mode: Option<CodexAuthMode>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub codex_base_url: Option<String>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub codex_wire_api: Option<CodexWireApi>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub codex_provider_key_ref: Option<crate::CredentialRef>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub codex_forward_env: Option<Vec<String>>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub codex_sandbox: Option<CodexSandbox>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub codex_approval_policy: Option<CodexApprovalPolicy>,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub codex_network_access: Option<bool>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub codex_allow_danger_bypass: Option<bool>,
791 #[serde(skip)]
798 pub broker: Option<BrokerClientConfig>,
799 #[serde(default, skip_serializing_if = "Vec::is_empty")]
805 pub remote_placements: Vec<RemoteActorPlacement>,
806 #[serde(default, skip_serializing_if = "Vec::is_empty")]
819 pub schedulable_placements: Vec<SchedulablePlacement>,
820 #[serde(default, skip_serializing_if = "Vec::is_empty")]
839 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
840}
841
842#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
845pub struct McpRoleAllowlistEntry {
846 pub role: String,
854 #[serde(default)]
862 pub tools: Vec<String>,
863}
864
865#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
881pub struct SchedulablePlacement {
882 pub role: String,
885 pub pool: String,
887 #[serde(default, skip_serializing_if = "String::is_empty")]
891 pub registry_url: String,
892 #[serde(default, skip_serializing_if = "Option::is_none")]
896 pub token_env: Option<String>,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub ca_cert_file: Option<String>,
901}
902
903#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
914pub struct RemoteActorPlacement {
915 pub role: String,
918 pub endpoint: String,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub token_env: Option<String>,
925 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub ca_cert_file: Option<String>,
929}
930
931#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
933pub struct BrokerClientConfig {
934 pub endpoint: String,
936 #[serde(default, skip_serializing)]
942 pub token: String,
943 #[serde(default, skip_serializing)]
945 pub token_encrypted: Option<String>,
946 #[serde(default, skip_serializing_if = "Option::is_none")]
948 pub credential_ref: Option<crate::CredentialRef>,
949 #[serde(default)]
952 pub configured: bool,
953}
954
955impl std::fmt::Debug for BrokerClientConfig {
956 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
957 formatter
958 .debug_struct("BrokerClientConfig")
959 .field("endpoint", &self.endpoint)
960 .field("credential_ref", &self.credential_ref)
961 .field("configured", &self.configured)
962 .finish_non_exhaustive()
963 }
964}
965
966#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
968pub struct DesktopChannelConfig {
969 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub enabled: Option<bool>,
977}
978
979#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
981pub struct NtfyChannelConfig {
982 #[serde(default)]
983 pub enabled: bool,
984 #[serde(default = "default_ntfy_base_url")]
986 pub base_url: String,
987 #[serde(default)]
990 pub topic: String,
991 #[serde(default, skip_serializing)]
997 pub token: Option<String>,
998 #[serde(default, skip_serializing)]
1000 pub token_encrypted: Option<String>,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub credential_ref: Option<crate::CredentialRef>,
1004 #[serde(default)]
1006 pub configured: bool,
1007}
1008
1009impl Default for NtfyChannelConfig {
1010 fn default() -> Self {
1011 Self {
1012 enabled: false,
1013 base_url: default_ntfy_base_url(),
1014 topic: String::new(),
1015 token: None,
1016 token_encrypted: None,
1017 credential_ref: None,
1018 configured: false,
1019 }
1020 }
1021}
1022
1023fn default_ntfy_base_url() -> String {
1024 "https://ntfy.sh".to_string()
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1029pub struct BarkChannelConfig {
1030 #[serde(default)]
1031 pub enabled: bool,
1032 #[serde(default = "default_bark_base_url")]
1034 pub base_url: String,
1035 #[serde(default, skip_serializing)]
1040 pub device_key: Option<String>,
1041 #[serde(default, skip_serializing)]
1043 pub device_key_encrypted: Option<String>,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub credential_ref: Option<crate::CredentialRef>,
1047 #[serde(default)]
1049 pub configured: bool,
1050}
1051
1052impl Default for BarkChannelConfig {
1053 fn default() -> Self {
1054 Self {
1055 enabled: false,
1056 base_url: default_bark_base_url(),
1057 device_key: None,
1058 device_key_encrypted: None,
1059 credential_ref: None,
1060 configured: false,
1061 }
1062 }
1063}
1064
1065fn default_bark_base_url() -> String {
1066 "https://api.day.app".to_string()
1067}
1068
1069#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1074pub struct NotificationsConfig {
1075 #[serde(default)]
1076 pub desktop: DesktopChannelConfig,
1077 #[serde(default)]
1078 pub ntfy: NtfyChannelConfig,
1079 #[serde(default)]
1080 pub bark: BarkChannelConfig,
1081}
1082
1083#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1087pub struct ConnectPlatformConfig {
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1102 pub id: Option<String>,
1103 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub project_id: Option<bamboo_domain::ProjectId>,
1108 #[serde(rename = "type")]
1112 pub platform_type: String,
1113 #[serde(default, skip_serializing)]
1119 pub token: Option<String>,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1122 pub token_encrypted: Option<String>,
1123 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub token_credential_ref: Option<crate::CredentialRef>,
1126 #[serde(default)]
1128 pub token_configured: bool,
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub app_id: Option<String>,
1133 #[serde(default, skip_serializing)]
1139 pub app_secret: Option<String>,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1142 pub app_secret_encrypted: Option<String>,
1143 #[serde(default, skip_serializing_if = "Option::is_none")]
1145 pub app_secret_credential_ref: Option<crate::CredentialRef>,
1146 #[serde(default)]
1148 pub app_secret_configured: bool,
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub domain: Option<String>,
1156 #[serde(default)]
1161 pub allow_from: Vec<String>,
1162 #[serde(default)]
1166 pub admin_from: Vec<String>,
1167}
1168
1169#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1173pub struct ConnectConfig {
1174 #[serde(default)]
1175 pub platforms: Vec<ConnectPlatformConfig>,
1176}
1177
1178fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1179 config.platforms.is_empty()
1180}
1181
1182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1189pub struct TrustedKey {
1190 pub label: String,
1192 pub algorithm: String,
1194 pub public_key: String,
1196}
1197
1198fn default_trusted_keys() -> Vec<TrustedKey> {
1205 vec![
1206 TrustedKey {
1207 label: "nova (bigduu official)".to_string(),
1208 algorithm: "ed25519".to_string(),
1209 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1210 .to_string(),
1211 },
1212 TrustedKey {
1213 label: "magpie (bigduu official)".to_string(),
1214 algorithm: "ed25519".to_string(),
1215 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1216 .to_string(),
1217 },
1218 ]
1219}
1220
1221fn default_trusted_hosts() -> Vec<String> {
1224 vec!["github.com/bigduu/".to_string()]
1225}
1226
1227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1244pub struct PluginTrustConfig {
1245 #[serde(default = "default_trusted_hosts")]
1256 pub trusted_hosts: Vec<String>,
1257 #[serde(default = "default_trusted_keys")]
1260 pub trusted_keys: Vec<TrustedKey>,
1261 #[serde(default)]
1275 pub enforcement: PluginTrustEnforcement,
1276}
1277
1278impl Default for PluginTrustConfig {
1279 fn default() -> Self {
1280 Self {
1281 trusted_hosts: default_trusted_hosts(),
1282 trusted_keys: default_trusted_keys(),
1283 enforcement: PluginTrustEnforcement::default(),
1284 }
1285 }
1286}
1287
1288impl PluginTrustConfig {
1289 pub fn is_host_trusted(&self, url: &str) -> bool {
1295 is_host_trusted(url, &self.trusted_hosts)
1296 }
1297
1298 pub fn enforcement_is_off(&self) -> bool {
1304 matches!(self.enforcement, PluginTrustEnforcement::Off)
1305 }
1306}
1307
1308#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1321#[serde(rename_all = "snake_case")]
1322pub enum PluginTrustEnforcement {
1323 #[default]
1328 Strict,
1329 Off,
1333}
1334
1335impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1336 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1337 where
1338 D: serde::Deserializer<'de>,
1339 {
1340 #[derive(Deserialize)]
1341 #[serde(untagged)]
1342 enum Repr {
1343 Bool(bool),
1344 Str(String),
1345 }
1346 match Repr::deserialize(deserializer)? {
1347 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1348 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1349 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1350 "strict" => Ok(PluginTrustEnforcement::Strict),
1351 "off" => Ok(PluginTrustEnforcement::Off),
1352 other => Err(serde::de::Error::custom(format!(
1353 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1354 \"off\""
1355 ))),
1356 },
1357 }
1358 }
1359}
1360
1361struct TrustedHostEntry<'a> {
1366 host: &'a str,
1367 path_prefix: &'a str,
1368}
1369
1370fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1374 match entry.find('/') {
1375 Some(index) => TrustedHostEntry {
1376 host: &entry[..index],
1377 path_prefix: &entry[index..],
1378 },
1379 None => TrustedHostEntry {
1380 host: entry,
1381 path_prefix: "",
1382 },
1383 }
1384}
1385
1386fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1398 if prefix.is_empty() || path == prefix {
1399 return true;
1400 }
1401 if prefix.ends_with('/') {
1402 return path.starts_with(prefix);
1403 }
1404 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1405}
1406
1407pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1431 let Ok(parsed) = url::Url::parse(url) else {
1432 return false;
1433 };
1434 if parsed.scheme() != "https" {
1435 return false;
1436 }
1437 let Some(host) = parsed.host_str() else {
1438 return false;
1439 };
1440 let host = host.to_ascii_lowercase();
1441 let path = parsed.path();
1442
1443 trusted_hosts.iter().any(|raw_entry| {
1444 let entry = raw_entry.trim().to_ascii_lowercase();
1445 let parsed_entry = parse_trusted_host_entry(&entry);
1446 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1447 })
1448}
1449
1450#[derive(Debug, Clone, Serialize, Deserialize)]
1455#[doc(hidden)]
1456pub struct ConfigValues {
1457 #[serde(default)]
1459 pub http_proxy: String,
1460 #[serde(default)]
1462 pub https_proxy: String,
1463 #[serde(skip_serializing)]
1467 pub proxy_auth: Option<ProxyAuth>,
1468 #[serde(default, skip_serializing_if = "Option::is_none")]
1470 pub proxy_auth_encrypted: Option<String>,
1471 #[serde(default, skip_serializing_if = "Option::is_none")]
1473 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1474 #[serde(default)]
1476 pub headless_auth: bool,
1477
1478 #[serde(default = "default_provider")]
1480 pub provider: String,
1481
1482 #[serde(default, skip_serializing_if = "Option::is_none")]
1484 pub defaults: Option<DefaultsConfig>,
1485
1486 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1493 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1494
1495 #[serde(default, skip_serializing_if = "Option::is_none")]
1499 pub default_provider_instance: Option<String>,
1500
1501 #[serde(default)]
1503 pub server: ServerConfig,
1504
1505 #[serde(default)]
1509 pub keyword_masking: KeywordMaskingConfig,
1510
1511 #[serde(default)]
1515 pub anthropic_model_mapping: AnthropicModelMapping,
1516
1517 #[serde(default)]
1521 pub gemini_model_mapping: GeminiModelMapping,
1522
1523 #[serde(default)]
1528 pub hooks: HooksConfig,
1529
1530 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1536 pub lifecycle_hooks: LifecycleHooksConfig,
1537
1538 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1542 pub tools: ToolsConfig,
1543
1544 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1549 pub skills: SkillsConfig,
1550
1551 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1555 pub env_vars: Vec<EnvVarEntry>,
1556
1557 #[serde(default, skip_serializing_if = "Option::is_none")]
1559 pub default_work_area: Option<DefaultWorkAreaConfig>,
1560
1561 #[serde(default, skip_serializing_if = "Option::is_none")]
1563 pub access_control: Option<AccessControlConfig>,
1564
1565 #[serde(default)]
1567 pub features: FeatureFlags,
1568
1569 #[serde(default)]
1574 pub run_budget: RunBudgetConfig,
1575
1576 #[serde(default)]
1578 pub stream_timeout: StreamTimeoutConfig,
1579
1580 #[serde(
1584 default,
1585 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1586 )]
1587 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1588
1589 #[serde(default, rename = "mcpServers", alias = "mcp")]
1595 pub mcp: bamboo_domain::mcp_config::McpConfig,
1596
1597 #[serde(default)]
1601 pub notifications: NotificationsConfig,
1602
1603 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1608 pub connect: ConnectConfig,
1609
1610 #[serde(default)]
1614 pub plugin_trust: PluginTrustConfig,
1615
1616 #[serde(default, flatten)]
1622 pub extra: BTreeMap<String, Value>,
1623}
1624
1625impl Default for ConfigValues {
1626 fn default() -> Self {
1627 Self {
1628 http_proxy: String::new(),
1629 https_proxy: String::new(),
1630 proxy_auth: None,
1631 proxy_auth_encrypted: None,
1632 proxy_auth_credential_ref: None,
1633 headless_auth: false,
1634 run_budget: RunBudgetConfig::default(),
1635 stream_timeout: StreamTimeoutConfig::default(),
1636 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1637 provider: default_provider(),
1638 provider_instances: HashMap::new(),
1639 default_provider_instance: None,
1640 server: ServerConfig::default(),
1641 keyword_masking: KeywordMaskingConfig::default(),
1642 anthropic_model_mapping: AnthropicModelMapping::default(),
1643 gemini_model_mapping: GeminiModelMapping::default(),
1644 hooks: HooksConfig::default(),
1645 lifecycle_hooks: LifecycleHooksConfig::default(),
1646 tools: ToolsConfig::default(),
1647 skills: SkillsConfig::default(),
1648 env_vars: Vec::new(),
1649 default_work_area: None,
1650 access_control: None,
1651 features: FeatureFlags::default(),
1652 defaults: None,
1653 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1654 notifications: NotificationsConfig::default(),
1655 connect: ConnectConfig::default(),
1656 plugin_trust: PluginTrustConfig::default(),
1657 extra: BTreeMap::new(),
1658 }
1659 }
1660}
1661
1662#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1665struct NetworkConfigSection {
1666 #[serde(default)]
1667 http_proxy: String,
1668 #[serde(default)]
1669 https_proxy: String,
1670 #[serde(skip_serializing)]
1671 proxy_auth: Option<ProxyAuth>,
1672 #[serde(default, skip_serializing_if = "Option::is_none")]
1673 proxy_auth_encrypted: Option<String>,
1674 #[serde(default, skip_serializing_if = "Option::is_none")]
1675 proxy_auth_credential_ref: Option<crate::CredentialRef>,
1676 #[serde(default)]
1677 headless_auth: bool,
1678 #[serde(default)]
1679 server: ServerConfig,
1680}
1681
1682#[derive(Debug, Clone, Serialize, Deserialize)]
1683struct ProviderRoutingConfigSection {
1684 #[serde(default = "default_provider")]
1685 provider: String,
1686 #[serde(default, skip_serializing_if = "Option::is_none")]
1687 defaults: Option<DefaultsConfig>,
1688 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1689 provider_instances: HashMap<String, ProviderInstanceConfig>,
1690 #[serde(default, skip_serializing_if = "Option::is_none")]
1691 default_provider_instance: Option<String>,
1692}
1693
1694impl Default for ProviderRoutingConfigSection {
1695 fn default() -> Self {
1696 Self {
1697 provider: default_provider(),
1698 defaults: None,
1699 provider_instances: HashMap::new(),
1700 default_provider_instance: None,
1701 }
1702 }
1703}
1704
1705#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1706struct ModelBehaviorConfigSection {
1707 #[serde(default)]
1708 keyword_masking: KeywordMaskingConfig,
1709 #[serde(default)]
1710 anthropic_model_mapping: AnthropicModelMapping,
1711 #[serde(default)]
1712 gemini_model_mapping: GeminiModelMapping,
1713 #[serde(default)]
1714 hooks: HooksConfig,
1715 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1716 lifecycle_hooks: LifecycleHooksConfig,
1717}
1718
1719#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1720struct ToolingConfigSection {
1721 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1722 tools: ToolsConfig,
1723 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1724 skills: SkillsConfig,
1725 #[serde(default, rename = "mcpServers", alias = "mcp")]
1726 mcp: bamboo_domain::mcp_config::McpConfig,
1727}
1728
1729#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1730struct WorkspaceConfigSection {
1731 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1732 env_vars: Vec<EnvVarEntry>,
1733 #[serde(default, skip_serializing_if = "Option::is_none")]
1734 default_work_area: Option<DefaultWorkAreaConfig>,
1735 #[serde(default, skip_serializing_if = "Option::is_none")]
1736 access_control: Option<AccessControlConfig>,
1737}
1738
1739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1740struct ExecutionConfigSection {
1741 #[serde(default)]
1742 features: FeatureFlags,
1743 #[serde(default)]
1744 run_budget: RunBudgetConfig,
1745 #[serde(default)]
1746 stream_timeout: StreamTimeoutConfig,
1747 #[serde(
1748 default,
1749 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1750 )]
1751 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1752}
1753
1754#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1755struct IntegrationConfigSection {
1756 #[serde(default)]
1757 notifications: NotificationsConfig,
1758 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1759 connect: ConnectConfig,
1760}
1761
1762#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1763struct PluginSecurityConfigSection {
1764 #[serde(default)]
1765 plugin_trust: PluginTrustConfig,
1766}
1767
1768macro_rules! count_fields {
1771 ($($field:ident),* $(,)?) => {
1772 <[()]>::len(&[$(count_fields!(@one $field)),*])
1773 };
1774 (@one $field:ident) => { () };
1775}
1776
1777macro_rules! define_counted_struct {
1778 (
1779 $(#[$struct_meta:meta])*
1780 $visibility:vis struct $name:ident {
1781 $(
1782 $(#[$field_meta:meta])*
1783 $field_visibility:vis $field:ident: $field_type:ty
1784 ),* $(,)?
1785 }
1786 count $count_visibility:vis $count_name:ident;
1787 ) => {
1788 $(#[$struct_meta])*
1789 $visibility struct $name {
1790 $(
1791 $(#[$field_meta])*
1792 $field_visibility $field: $field_type,
1793 )*
1794 }
1795
1796 $count_visibility const $count_name: usize = count_fields!($($field),*);
1797 };
1798}
1799
1800define_counted_struct! {
1801 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
1807 struct ConfigRoot {
1808 #[serde(flatten)]
1809 network: NetworkConfigSection,
1810 #[serde(flatten)]
1811 provider_routing: ProviderRoutingConfigSection,
1812 #[serde(flatten)]
1813 model_behavior: ModelBehaviorConfigSection,
1814 #[serde(flatten)]
1815 tooling: ToolingConfigSection,
1816 #[serde(flatten)]
1817 workspace: WorkspaceConfigSection,
1818 #[serde(flatten)]
1819 execution: ExecutionConfigSection,
1820 #[serde(flatten)]
1821 integrations: IntegrationConfigSection,
1822 #[serde(flatten)]
1823 plugin_security: PluginSecurityConfigSection,
1824 #[serde(default, flatten)]
1825 extra: BTreeMap<String, Value>,
1826 }
1827 count pub PERSISTED_ROOT_FIELD_COUNT;
1828}
1829
1830impl From<ConfigValues> for ConfigRoot {
1831 fn from(values: ConfigValues) -> Self {
1832 let ConfigValues {
1835 http_proxy,
1836 https_proxy,
1837 proxy_auth,
1838 proxy_auth_encrypted,
1839 proxy_auth_credential_ref,
1840 headless_auth,
1841 provider,
1842 defaults,
1843 provider_instances,
1844 default_provider_instance,
1845 server,
1846 keyword_masking,
1847 anthropic_model_mapping,
1848 gemini_model_mapping,
1849 hooks,
1850 lifecycle_hooks,
1851 tools,
1852 skills,
1853 env_vars,
1854 default_work_area,
1855 access_control,
1856 features,
1857 run_budget,
1858 stream_timeout,
1859 cluster_fabric,
1860 mcp,
1861 notifications,
1862 connect,
1863 plugin_trust,
1864 extra,
1865 } = values;
1866
1867 Self {
1868 network: NetworkConfigSection {
1869 http_proxy,
1870 https_proxy,
1871 proxy_auth,
1872 proxy_auth_encrypted,
1873 proxy_auth_credential_ref,
1874 headless_auth,
1875 server,
1876 },
1877 provider_routing: ProviderRoutingConfigSection {
1878 provider,
1879 defaults,
1880 provider_instances,
1881 default_provider_instance,
1882 },
1883 model_behavior: ModelBehaviorConfigSection {
1884 keyword_masking,
1885 anthropic_model_mapping,
1886 gemini_model_mapping,
1887 hooks,
1888 lifecycle_hooks,
1889 },
1890 tooling: ToolingConfigSection { tools, skills, mcp },
1891 workspace: WorkspaceConfigSection {
1892 env_vars,
1893 default_work_area,
1894 access_control,
1895 },
1896 execution: ExecutionConfigSection {
1897 features,
1898 run_budget,
1899 stream_timeout,
1900 cluster_fabric,
1901 },
1902 integrations: IntegrationConfigSection {
1903 notifications,
1904 connect,
1905 },
1906 plugin_security: PluginSecurityConfigSection { plugin_trust },
1907 extra,
1908 }
1909 }
1910}
1911
1912impl From<ConfigRoot> for ConfigValues {
1913 fn from(root: ConfigRoot) -> Self {
1914 let ConfigRoot {
1917 network,
1918 provider_routing,
1919 model_behavior,
1920 tooling,
1921 workspace,
1922 execution,
1923 integrations,
1924 plugin_security,
1925 extra,
1926 } = root;
1927 let NetworkConfigSection {
1928 http_proxy,
1929 https_proxy,
1930 proxy_auth,
1931 proxy_auth_encrypted,
1932 proxy_auth_credential_ref,
1933 headless_auth,
1934 server,
1935 } = network;
1936 let ProviderRoutingConfigSection {
1937 provider,
1938 defaults,
1939 provider_instances,
1940 default_provider_instance,
1941 } = provider_routing;
1942 let ModelBehaviorConfigSection {
1943 keyword_masking,
1944 anthropic_model_mapping,
1945 gemini_model_mapping,
1946 hooks,
1947 lifecycle_hooks,
1948 } = model_behavior;
1949 let ToolingConfigSection { tools, skills, mcp } = tooling;
1950 let WorkspaceConfigSection {
1951 env_vars,
1952 default_work_area,
1953 access_control,
1954 } = workspace;
1955 let ExecutionConfigSection {
1956 features,
1957 run_budget,
1958 stream_timeout,
1959 cluster_fabric,
1960 } = execution;
1961 let IntegrationConfigSection {
1962 notifications,
1963 connect,
1964 } = integrations;
1965 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
1966
1967 Self {
1968 http_proxy,
1969 https_proxy,
1970 proxy_auth,
1971 proxy_auth_encrypted,
1972 proxy_auth_credential_ref,
1973 headless_auth,
1974 provider,
1975 defaults,
1976 provider_instances,
1977 default_provider_instance,
1978 server,
1979 keyword_masking,
1980 anthropic_model_mapping,
1981 gemini_model_mapping,
1982 hooks,
1983 lifecycle_hooks,
1984 tools,
1985 skills,
1986 env_vars,
1987 default_work_area,
1988 access_control,
1989 features,
1990 run_budget,
1991 stream_timeout,
1992 cluster_fabric,
1993 mcp,
1994 notifications,
1995 connect,
1996 plugin_trust,
1997 extra,
1998 }
1999 }
2000}
2001
2002define_counted_struct! {
2003 #[derive(Debug, Clone)]
2006 pub struct Config {
2007 values: ConfigValues,
2008 pub(crate) memory: crate::MemoryConfigModule,
2009 pub(crate) subagents: crate::SubagentsConfigModule,
2010 pub(crate) providers: crate::ProviderConfigsModule,
2011 recovery_status: Option<ConfigRecoveryStatus>,
2012 }
2013 count pub CONFIG_FIELD_COUNT;
2014}
2015
2016pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2018const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2019const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2020
2021impl std::ops::Deref for Config {
2022 type Target = ConfigValues;
2023
2024 fn deref(&self) -> &Self::Target {
2025 &self.values
2026 }
2027}
2028
2029impl std::ops::DerefMut for Config {
2030 fn deref_mut(&mut self) -> &mut Self::Target {
2031 &mut self.values
2032 }
2033}
2034
2035impl Serialize for Config {
2036 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2037 where
2038 S: serde::Serializer,
2039 {
2040 self.to_compatibility_value()
2041 .map_err(serde::ser::Error::custom)?
2042 .serialize(serializer)
2043 }
2044}
2045
2046impl<'de> Deserialize<'de> for Config {
2047 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2048 where
2049 D: serde::Deserializer<'de>,
2050 {
2051 use serde::de::Error;
2052
2053 let mut value = Value::deserialize(deserializer)?;
2054 let object = value
2055 .as_object_mut()
2056 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2057 let memory = object
2058 .remove("memory")
2059 .map(serde_json::from_value)
2060 .transpose()
2061 .map_err(D::Error::custom)?
2062 .unwrap_or_default();
2063 let subagents = object
2064 .remove("subagents")
2065 .map(serde_json::from_value)
2066 .transpose()
2067 .map_err(D::Error::custom)?
2068 .unwrap_or_default();
2069 let providers = object
2070 .remove("providers")
2071 .map(serde_json::from_value)
2072 .transpose()
2073 .map_err(D::Error::custom)?
2074 .unwrap_or_default();
2075 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2076
2077 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2078 }
2079}
2080
2081#[derive(Debug, Clone, PartialEq, Serialize)]
2083#[serde(tag = "kind", rename_all = "snake_case")]
2084pub enum ConfigRecoverySource {
2085 Salvaged { fields: Vec<String> },
2090 Backup { generation: usize },
2093 Defaults,
2095}
2096
2097#[derive(Debug, Clone, PartialEq, Serialize)]
2109pub struct ConfigRecoveryStatus {
2110 pub source: ConfigRecoverySource,
2112 pub quarantine_path: Option<PathBuf>,
2118 pub confirmed: bool,
2121}
2122
2123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2127pub struct ProviderConfigs {
2128 #[serde(skip_serializing_if = "Option::is_none")]
2130 pub openai: Option<OpenAIConfig>,
2131 #[serde(skip_serializing_if = "Option::is_none")]
2133 pub anthropic: Option<AnthropicConfig>,
2134 #[serde(skip_serializing_if = "Option::is_none")]
2136 pub gemini: Option<GeminiConfig>,
2137 #[serde(skip_serializing_if = "Option::is_none")]
2139 pub copilot: Option<CopilotConfig>,
2140 #[serde(skip_serializing_if = "Option::is_none")]
2142 pub bodhi: Option<BodhiConfig>,
2143
2144 #[serde(default, flatten)]
2146 pub extra: BTreeMap<String, Value>,
2147}
2148
2149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2151pub struct FeatureFlags {
2152 #[serde(default)]
2154 pub provider_model_ref: bool,
2155 #[serde(default)]
2157 pub dynamic_model_routing: bool,
2158}
2159
2160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2164pub struct DefaultsConfig {
2165 pub chat: bamboo_domain::ProviderModelRef,
2166 #[serde(default, skip_serializing_if = "Option::is_none")]
2167 pub fast: Option<bamboo_domain::ProviderModelRef>,
2168 #[serde(default, skip_serializing_if = "Option::is_none")]
2169 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2170 #[serde(default, skip_serializing_if = "Option::is_none")]
2171 pub vision: Option<bamboo_domain::ProviderModelRef>,
2172 #[serde(default, skip_serializing_if = "Option::is_none")]
2173 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2174 #[serde(default, skip_serializing_if = "Option::is_none")]
2177 pub planning: Option<bamboo_domain::ProviderModelRef>,
2178 #[serde(default, skip_serializing_if = "Option::is_none")]
2181 pub search: Option<bamboo_domain::ProviderModelRef>,
2182 #[serde(default, skip_serializing_if = "Option::is_none")]
2185 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2186 #[serde(
2189 default,
2190 skip_serializing_if = "Option::is_none",
2191 alias = "sub_session"
2192 )]
2193 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2194 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2198 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2199}
2200
2201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2203pub struct HooksConfig {
2204 #[serde(default)]
2206 pub image_fallback: ImageFallbackHookConfig,
2207}
2208
2209pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2211pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2213pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2217
2218pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2220 "SessionStart",
2221 "UserPromptSubmit",
2222 "PreToolUse",
2223 "PostToolUse",
2224 "Stop",
2225 "SessionEnd",
2226 "PreCompact",
2227 "Notification",
2228];
2229
2230fn default_lifecycle_hook_timeout_ms() -> u64 {
2231 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2232}
2233
2234fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2235 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2236}
2237
2238fn lifecycle_hook_enabled_default() -> bool {
2239 true
2240}
2241
2242fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2243 *value
2244}
2245
2246#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2251pub struct LifecycleHooksConfig {
2252 #[serde(default)]
2253 pub enabled: bool,
2254 #[serde(
2255 default,
2256 rename = "SessionStart",
2257 skip_serializing_if = "Vec::is_empty"
2258 )]
2259 pub session_start: Vec<LifecycleHookGroup>,
2260 #[serde(
2261 default,
2262 rename = "UserPromptSubmit",
2263 skip_serializing_if = "Vec::is_empty"
2264 )]
2265 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2266 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2267 pub pre_tool_use: Vec<LifecycleHookGroup>,
2268 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2269 pub post_tool_use: Vec<LifecycleHookGroup>,
2270 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2271 pub stop: Vec<LifecycleHookGroup>,
2272 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2273 pub session_end: Vec<LifecycleHookGroup>,
2274 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2275 pub pre_compact: Vec<LifecycleHookGroup>,
2276 #[serde(
2277 default,
2278 rename = "Notification",
2279 skip_serializing_if = "Vec::is_empty"
2280 )]
2281 pub notification: Vec<LifecycleHookGroup>,
2282}
2283
2284impl LifecycleHooksConfig {
2285 pub fn is_empty(&self) -> bool {
2286 !self.enabled
2287 && self.session_start.is_empty()
2288 && self.user_prompt_submit.is_empty()
2289 && self.pre_tool_use.is_empty()
2290 && self.post_tool_use.is_empty()
2291 && self.stop.is_empty()
2292 && self.session_end.is_empty()
2293 && self.pre_compact.is_empty()
2294 && self.notification.is_empty()
2295 }
2296}
2297
2298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2300pub struct LifecycleHookGroup {
2301 #[serde(
2304 default = "lifecycle_hook_enabled_default",
2305 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2306 )]
2307 pub enabled: bool,
2308 #[serde(default, skip_serializing_if = "Option::is_none")]
2309 pub matcher: Option<String>,
2310 #[serde(default)]
2311 pub hooks: Vec<LifecycleHookHandler>,
2312}
2313
2314impl Default for LifecycleHookGroup {
2315 fn default() -> Self {
2316 Self {
2317 enabled: true,
2318 matcher: None,
2319 hooks: Vec::new(),
2320 }
2321 }
2322}
2323
2324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2329#[serde(tag = "type", rename_all = "lowercase")]
2330pub enum LifecycleHookHandler {
2331 Command {
2332 command: String,
2333 #[serde(
2334 default = "default_lifecycle_hook_timeout_ms",
2335 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2336 )]
2337 timeout_ms: u64,
2338 },
2339 Script {
2340 path: String,
2341 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2342 runner: LifecycleScriptRunner,
2343 #[serde(
2344 default = "default_lifecycle_hook_timeout_ms",
2345 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2346 )]
2347 timeout_ms: u64,
2348 },
2349}
2350
2351#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2357#[serde(rename_all = "lowercase")]
2358pub enum LifecycleScriptRunner {
2359 #[default]
2360 Auto,
2361 Node,
2362 Bun,
2363 Python,
2364 Bash,
2365 PowerShell,
2366 Cmd,
2367}
2368
2369impl LifecycleScriptRunner {
2370 pub fn is_auto(&self) -> bool {
2371 matches!(self, Self::Auto)
2372 }
2373
2374 pub fn as_str(self) -> &'static str {
2375 match self {
2376 Self::Auto => "auto",
2377 Self::Node => "node",
2378 Self::Bun => "bun",
2379 Self::Python => "python",
2380 Self::Bash => "bash",
2381 Self::PowerShell => "powershell",
2382 Self::Cmd => "cmd",
2383 }
2384 }
2385
2386 pub fn supports_path(self, path: &str) -> bool {
2388 let extension = lifecycle_script_extension(path);
2389 match self {
2390 Self::Auto => extension.is_some(),
2391 Self::Node | Self::Bun => {
2392 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2393 }
2394 Self::Python => matches!(extension.as_deref(), Some("py")),
2395 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2396 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2397 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2398 }
2399 }
2400}
2401
2402pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2405 let extension = std::path::Path::new(path)
2406 .extension()?
2407 .to_str()?
2408 .to_ascii_lowercase();
2409 matches!(
2410 extension.as_str(),
2411 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2412 )
2413 .then_some(extension)
2414}
2415
2416impl LifecycleHookHandler {
2417 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2418 Self::Command {
2419 command: command.into(),
2420 timeout_ms,
2421 }
2422 }
2423
2424 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2425 Self::Script {
2426 path: path.into(),
2427 runner,
2428 timeout_ms,
2429 }
2430 }
2431
2432 pub fn timeout_ms(&self) -> u64 {
2433 match self {
2434 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2435 }
2436 }
2437}
2438
2439#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2446pub struct RequestOverridesConfig {
2447 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2449 pub common: RequestScopeOverride,
2450 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2452 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2455 pub rules: Vec<ModelRequestRule>,
2456}
2457
2458#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2460pub struct ModelRequestRule {
2461 pub model_pattern: String,
2463 #[serde(default, skip_serializing_if = "Option::is_none")]
2465 pub endpoint: Option<String>,
2466 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2468 pub scope: RequestScopeOverride,
2469}
2470
2471#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2473pub struct RequestScopeOverride {
2474 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2476 pub headers: BTreeMap<String, TemplateExpr>,
2477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2479 pub body_patch: Vec<BodyPatch>,
2480}
2481
2482impl RequestScopeOverride {
2483 pub fn is_empty(&self) -> bool {
2484 self.headers.is_empty() && self.body_patch.is_empty()
2485 }
2486}
2487
2488#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2490pub struct BodyPatch {
2491 pub path: String,
2493 #[serde(default)]
2495 pub op: BodyPatchOp,
2496 #[serde(default, skip_serializing_if = "Option::is_none")]
2498 pub value: Option<PatchValue>,
2499}
2500
2501#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2503#[serde(rename_all = "snake_case")]
2504pub enum BodyPatchOp {
2505 #[default]
2506 Set,
2507 Remove,
2508}
2509
2510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2512#[serde(untagged)]
2513pub enum PatchValue {
2514 Template(TemplateExpr),
2515 Json(Value),
2516}
2517
2518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2520#[serde(untagged)]
2521pub enum TemplateExpr {
2522 Literal(String),
2524 Structured(TemplateExprSpec),
2526}
2527
2528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2530#[serde(tag = "type", rename_all = "snake_case")]
2531pub enum TemplateExprSpec {
2532 Literal { value: String },
2534 EnvRef {
2536 name: String,
2537 #[serde(default, skip_serializing_if = "Option::is_none")]
2538 fallback: Option<String>,
2539 },
2540 Generated { generator: GeneratedValue },
2542 Format { template: String },
2544}
2545
2546#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2548#[serde(rename_all = "snake_case")]
2549pub enum GeneratedValue {
2550 Uuid,
2551 UnixMs,
2552}
2553
2554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2556pub struct ToolsConfig {
2557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2559 pub disabled: Vec<String>,
2560}
2561
2562impl ToolsConfig {
2563 fn is_empty(&self) -> bool {
2564 self.disabled.is_empty()
2565 }
2566}
2567
2568#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2570pub struct SkillsConfig {
2571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2573 pub disabled: Vec<String>,
2574}
2575
2576impl SkillsConfig {
2577 fn is_empty(&self) -> bool {
2578 self.disabled.is_empty()
2579 }
2580}
2581
2582#[derive(Debug, Clone, Serialize, Deserialize)]
2587pub struct ImageFallbackHookConfig {
2588 #[serde(default = "default_true_hooks")]
2589 pub enabled: bool,
2590
2591 #[serde(default = "default_image_fallback_mode")]
2593 pub mode: String,
2594}
2595
2596impl Default for ImageFallbackHookConfig {
2597 fn default() -> Self {
2598 Self {
2599 enabled: default_true_hooks(),
2600 mode: default_image_fallback_mode(),
2601 }
2602 }
2603}
2604
2605fn default_image_fallback_mode() -> String {
2606 "placeholder".to_string()
2607}
2608
2609fn default_true_hooks() -> bool {
2610 false
2613}
2614
2615pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2627
2628#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2629pub struct OpenAIConfig {
2630 #[serde(default, skip_serializing)]
2634 pub api_key: String,
2635 #[serde(default, skip_serializing_if = "Option::is_none")]
2637 pub api_key_encrypted: Option<String>,
2638 #[serde(default, skip_serializing_if = "Option::is_none")]
2640 pub credential_ref: Option<crate::CredentialRef>,
2641 #[serde(skip)]
2646 pub api_key_from_env: bool,
2647 #[serde(skip_serializing_if = "Option::is_none")]
2649 pub base_url: Option<String>,
2650 #[serde(skip_serializing_if = "Option::is_none")]
2652 pub model: Option<String>,
2653 #[serde(default, skip_serializing_if = "Option::is_none")]
2656 pub fast_model: Option<String>,
2657 #[serde(default, skip_serializing_if = "Option::is_none")]
2660 pub vision_model: Option<String>,
2661 #[serde(skip_serializing_if = "Option::is_none")]
2663 pub reasoning_effort: Option<ReasoningEffort>,
2664
2665 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2672 pub responses_only_models: Vec<String>,
2673 #[serde(default, skip_serializing_if = "Option::is_none")]
2675 pub request_overrides: Option<RequestOverridesConfig>,
2676
2677 #[serde(default, flatten)]
2679 pub extra: BTreeMap<String, Value>,
2680}
2681
2682impl OpenAIConfig {
2683 pub fn explicit_prompt_cache_enabled(&self) -> bool {
2690 self.extra
2691 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
2692 .and_then(Value::as_bool)
2693 .unwrap_or(true)
2694 }
2695}
2696
2697#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2709pub struct AnthropicConfig {
2710 #[serde(default, skip_serializing)]
2714 pub api_key: String,
2715 #[serde(default, skip_serializing_if = "Option::is_none")]
2717 pub api_key_encrypted: Option<String>,
2718 #[serde(default, skip_serializing_if = "Option::is_none")]
2720 pub credential_ref: Option<crate::CredentialRef>,
2721 #[serde(skip)]
2726 pub api_key_from_env: bool,
2727 #[serde(skip_serializing_if = "Option::is_none")]
2729 pub base_url: Option<String>,
2730 #[serde(skip_serializing_if = "Option::is_none")]
2732 pub model: Option<String>,
2733 #[serde(default, skip_serializing_if = "Option::is_none")]
2736 pub fast_model: Option<String>,
2737 #[serde(default, skip_serializing_if = "Option::is_none")]
2740 pub vision_model: Option<String>,
2741 #[serde(skip_serializing_if = "Option::is_none")]
2743 pub max_tokens: Option<u32>,
2744 #[serde(skip_serializing_if = "Option::is_none")]
2746 pub reasoning_effort: Option<ReasoningEffort>,
2747 #[serde(default, skip_serializing_if = "Option::is_none")]
2749 pub request_overrides: Option<RequestOverridesConfig>,
2750
2751 #[serde(default, skip_serializing_if = "Option::is_none")]
2768 pub thinking_replay_always: Option<bool>,
2769
2770 #[serde(default, flatten)]
2772 pub extra: BTreeMap<String, Value>,
2773}
2774
2775#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2786pub struct GeminiConfig {
2787 #[serde(default, skip_serializing)]
2791 pub api_key: String,
2792 #[serde(default, skip_serializing_if = "Option::is_none")]
2794 pub api_key_encrypted: Option<String>,
2795 #[serde(default, skip_serializing_if = "Option::is_none")]
2797 pub credential_ref: Option<crate::CredentialRef>,
2798 #[serde(skip)]
2803 pub api_key_from_env: bool,
2804 #[serde(skip_serializing_if = "Option::is_none")]
2806 pub base_url: Option<String>,
2807 #[serde(skip_serializing_if = "Option::is_none")]
2809 pub model: Option<String>,
2810 #[serde(default, skip_serializing_if = "Option::is_none")]
2813 pub fast_model: Option<String>,
2814 #[serde(default, skip_serializing_if = "Option::is_none")]
2817 pub vision_model: Option<String>,
2818 #[serde(skip_serializing_if = "Option::is_none")]
2820 pub reasoning_effort: Option<ReasoningEffort>,
2821 #[serde(default, skip_serializing_if = "Option::is_none")]
2823 pub request_overrides: Option<RequestOverridesConfig>,
2824
2825 #[serde(default, flatten)]
2827 pub extra: BTreeMap<String, Value>,
2828}
2829
2830#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2842pub struct CopilotConfig {
2843 #[serde(default)]
2845 pub enabled: bool,
2846 #[serde(default)]
2848 pub headless_auth: bool,
2849 #[serde(skip_serializing_if = "Option::is_none")]
2851 pub model: Option<String>,
2852 #[serde(default, skip_serializing_if = "Option::is_none")]
2855 pub fast_model: Option<String>,
2856 #[serde(default, skip_serializing_if = "Option::is_none")]
2859 pub vision_model: Option<String>,
2860 #[serde(skip_serializing_if = "Option::is_none")]
2862 pub reasoning_effort: Option<ReasoningEffort>,
2863
2864 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2873 pub responses_only_models: Vec<String>,
2874 #[serde(default, skip_serializing_if = "Option::is_none")]
2876 pub request_overrides: Option<RequestOverridesConfig>,
2877
2878 #[serde(default, flatten)]
2880 pub extra: BTreeMap<String, Value>,
2881}
2882
2883#[derive(Debug, Clone, Serialize, Deserialize)]
2888pub struct BodhiConfig {
2889 #[serde(default, skip_serializing)]
2891 pub api_key: String,
2892 #[serde(default, skip_serializing_if = "Option::is_none")]
2894 pub api_key_encrypted: Option<String>,
2895 #[serde(default, skip_serializing_if = "Option::is_none")]
2897 pub credential_ref: Option<crate::CredentialRef>,
2898 #[serde(skip_serializing_if = "Option::is_none")]
2900 pub base_url: Option<String>,
2901 #[serde(skip_serializing_if = "Option::is_none")]
2903 pub target_provider: Option<String>,
2904 #[serde(skip_serializing_if = "Option::is_none")]
2906 pub reasoning_effort: Option<ReasoningEffort>,
2907
2908 #[serde(default, flatten)]
2910 pub extra: BTreeMap<String, Value>,
2911}
2912
2913fn default_provider() -> String {
2915 "anthropic".to_string()
2916}
2917
2918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2950pub struct ProviderInstanceConfig {
2951 pub provider_type: String,
2956
2957 #[serde(default, skip_serializing_if = "Option::is_none")]
2959 pub label: Option<String>,
2960
2961 #[serde(default, skip_serializing)]
2963 pub api_key: String,
2964
2965 #[serde(default, skip_serializing_if = "Option::is_none")]
2968 pub api_key_encrypted: Option<String>,
2969
2970 #[serde(default, skip_serializing_if = "Option::is_none")]
2972 pub credential_ref: Option<crate::CredentialRef>,
2973
2974 #[serde(default, skip_serializing_if = "Option::is_none")]
2976 pub base_url: Option<String>,
2977
2978 #[serde(default, skip_serializing_if = "Option::is_none")]
2980 pub model: Option<String>,
2981
2982 #[serde(default, skip_serializing_if = "Option::is_none")]
2984 pub fast_model: Option<String>,
2985
2986 #[serde(default, skip_serializing_if = "Option::is_none")]
2988 pub vision_model: Option<String>,
2989
2990 #[serde(default, skip_serializing_if = "Option::is_none")]
2992 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
2993
2994 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2996 pub responses_only_models: Vec<String>,
2997
2998 #[serde(default, skip_serializing_if = "Option::is_none")]
3000 pub request_overrides: Option<RequestOverridesConfig>,
3001
3002 #[serde(default = "default_true")]
3005 pub enabled: bool,
3006
3007 #[serde(default, flatten)]
3009 pub extra: BTreeMap<String, Value>,
3010}
3011
3012fn default_true() -> bool {
3013 true
3014}
3015
3016fn default_port() -> u16 {
3018 9562
3019}
3020
3021fn default_bind() -> String {
3023 "127.0.0.1".to_string()
3024}
3025
3026fn default_workers() -> usize {
3028 10
3029}
3030
3031fn default_data_dir() -> PathBuf {
3033 super::paths::bamboo_dir()
3034}
3035
3036#[derive(Debug, Clone, Serialize, Deserialize)]
3038pub struct ServerConfig {
3039 #[serde(default = "default_port")]
3041 pub port: u16,
3042
3043 #[serde(default = "default_bind")]
3045 pub bind: String,
3046
3047 pub static_dir: Option<PathBuf>,
3049
3050 #[serde(default = "default_workers")]
3052 pub workers: usize,
3053
3054 #[serde(default, skip_serializing_if = "Option::is_none")]
3062 pub tls: Option<TlsConfig>,
3063
3064 #[serde(default, flatten)]
3066 pub extra: BTreeMap<String, Value>,
3067}
3068
3069impl Default for ServerConfig {
3070 fn default() -> Self {
3071 Self {
3072 port: default_port(),
3073 bind: default_bind(),
3074 static_dir: None,
3075 workers: default_workers(),
3076 tls: None,
3077 extra: BTreeMap::new(),
3078 }
3079 }
3080}
3081
3082#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3088pub struct TlsConfig {
3089 pub cert_file: PathBuf,
3091 pub key_file: PathBuf,
3093}
3094
3095#[derive(Debug, Clone, Serialize, Deserialize)]
3097pub struct ProxyAuth {
3098 pub username: String,
3100 pub password: String,
3102}
3103
3104fn parse_bool_env(value: &str) -> bool {
3108 matches!(
3109 value.trim().to_ascii_lowercase().as_str(),
3110 "1" | "true" | "yes" | "y" | "on"
3111 )
3112}
3113
3114fn expand_user_path(value: &str) -> PathBuf {
3115 let trimmed = value.trim();
3116 if let Some(rest) = trimmed.strip_prefix("~/") {
3117 if let Some(home) = dirs::home_dir() {
3118 return home.join(rest);
3119 }
3120 }
3121 PathBuf::from(trimmed)
3122}
3123
3124impl Default for Config {
3125 fn default() -> Self {
3126 Self::create_default()
3135 }
3136}
3137
3138#[derive(Debug, Clone, PartialEq, Eq)]
3140pub struct PromptSafeEnvVarEntry {
3141 pub name: String,
3142 pub secret: bool,
3143 pub description: Option<String>,
3144}
3145
3146static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3150
3151static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3152
3153fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3154 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3155}
3156
3157fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3158 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3159}
3160
3161impl Config {
3162 fn from_parts(
3163 values: ConfigValues,
3164 memory: Option<MemoryConfig>,
3165 subagents: SubagentsConfig,
3166 providers: ProviderConfigs,
3167 ) -> Self {
3168 Self {
3169 values,
3170 memory: crate::MemoryConfigModule(memory),
3171 subagents: crate::SubagentsConfigModule(subagents),
3172 providers: crate::ProviderConfigsModule(providers),
3173 recovery_status: None,
3174 }
3175 }
3176
3177 pub(crate) fn section_values(&self) -> ConfigValues {
3183 self.values.clone()
3184 }
3185
3186 pub(crate) fn from_section_parts(
3187 values: ConfigValues,
3188 memory: Option<MemoryConfig>,
3189 subagents: SubagentsConfig,
3190 providers: ProviderConfigs,
3191 ) -> Self {
3192 Self::from_parts(values, memory, subagents, providers)
3193 }
3194
3195 pub fn memory(&self) -> &Option<MemoryConfig> {
3197 &self.memory.0
3198 }
3199
3200 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3201 &mut self.memory.0
3202 }
3203
3204 pub fn subagents(&self) -> &SubagentsConfig {
3206 &self.subagents.0
3207 }
3208
3209 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3210 &mut self.subagents.0
3211 }
3212
3213 pub fn providers(&self) -> &ProviderConfigs {
3215 &self.providers.0
3216 }
3217
3218 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3219 &mut self.providers.0
3220 }
3221
3222 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3228 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3229 let object = value
3230 .as_object_mut()
3231 .expect("ConfigRoot always serializes as a JSON object");
3232 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3233 object.insert(
3234 "subagents".to_string(),
3235 serde_json::to_value(self.subagents())?,
3236 );
3237 object.insert(
3238 "providers".to_string(),
3239 serde_json::to_value(self.providers())?,
3240 );
3241 Ok(value)
3242 }
3243
3244 pub fn new() -> Self {
3263 Self::from_data_dir(None)
3264 }
3265
3266 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3275 let data_dir = data_dir
3277 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3278 .unwrap_or_else(default_data_dir);
3279
3280 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3285 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3286 .map_err(|error| {
3287 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3288 error
3289 })
3290 .is_ok();
3291 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3292 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3293 .map_err(|error| {
3294 tracing::warn!(error = %error, "cluster credential migration unavailable");
3295 error
3296 })
3297 .is_ok();
3298
3299 let config_path = data_dir.join("config.json");
3300
3301 let mut config = if config_path.exists() {
3302 if let Ok(content) = std::fs::read_to_string(&config_path) {
3303 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3304 tracing::warn!(
3314 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3315 e
3316 );
3317 let quarantine_path = quarantine_corrupt_config(&config_path);
3318 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3319 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3320 .or_else(|| {
3321 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3322 (cfg, ConfigRecoverySource::Backup { generation })
3323 })
3324 })
3325 .unwrap_or_else(|| {
3326 tracing::warn!(
3327 "Could not salvage and no usable config.json.bak; using defaults"
3328 );
3329 (Self::create_default(), ConfigRecoverySource::Defaults)
3330 });
3331 recovered.recovery_status = Some(ConfigRecoveryStatus {
3332 source,
3333 quarantine_path,
3334 confirmed: false,
3335 });
3336 recovered
3337 })
3338 } else {
3339 Self::create_default()
3340 }
3341 } else {
3342 Self::create_default()
3343 };
3344
3345 let mut memory_module = config.memory.clone();
3351 match memory_module.load_sync(&data_dir) {
3352 Ok(true) => config.memory = memory_module,
3353 Ok(false) => {}
3354 Err(error) => tracing::warn!(
3355 "Failed to load memory.json; using legacy config.json memory: {error}"
3356 ),
3357 }
3358 let mut subagents_module = config.subagents.clone();
3359 match subagents_module.load_sync(&data_dir) {
3360 Ok(true) => config.subagents = subagents_module,
3361 Ok(false) => {}
3362 Err(error) => tracing::warn!(
3363 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3364 ),
3365 }
3366 if provider_mcp_ready {
3367 let mut providers_module = config.providers.clone();
3368 match providers_module.load_sync(&data_dir) {
3369 Ok(true) => config.providers = providers_module,
3370 Ok(false) => {}
3371 Err(error) => tracing::warn!(
3372 "Failed to load providers.json; using legacy config.json providers: {error}"
3373 ),
3374 }
3375 }
3376
3377 config.hydrate_proxy_auth_from_encrypted();
3379 if provider_mcp_ready {
3380 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3381 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3382 config.proxy_auth = None;
3383 }
3384 }
3385 config.hydrate_provider_api_keys_from_encrypted();
3387 config.hydrate_provider_instance_api_keys_from_encrypted();
3389 config.hydrate_mcp_secrets_from_encrypted();
3391 if provider_mcp_ready {
3392 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3393 tracing::warn!(error = %error, "provider credential hydration unavailable");
3394 }
3395 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3396 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3397 }
3398 }
3399 config.hydrate_env_vars_from_encrypted();
3401 if provider_mcp_ready {
3402 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3403 tracing::warn!(error = %error, "env credential hydration unavailable");
3404 for entry in &mut config.env_vars {
3405 if entry.secret {
3406 entry.value.clear();
3407 }
3408 }
3409 }
3410 }
3411 if cluster_ready {
3416 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3417 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3418 config.clear_cluster_runtime_credentials();
3419 }
3420 } else {
3421 config.clear_cluster_runtime_credentials();
3422 }
3423 config.hydrate_broker_token_from_encrypted();
3425 config.hydrate_notifications_from_encrypted();
3427 if provider_mcp_ready {
3428 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3429 tracing::warn!(error = %error, "notification credential hydration unavailable");
3430 config.notifications.ntfy.token = None;
3431 config.notifications.bark.device_key = None;
3432 }
3433 } else {
3434 config.notifications.ntfy.token = None;
3439 config.notifications.bark.device_key = None;
3440 }
3441 config.merge_connect_config(&data_dir);
3446 scrub_legacy_connect_from_config_backups(&data_dir);
3456 config.hydrate_connect_platform_tokens_from_encrypted();
3458 if provider_mcp_ready {
3459 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3460 tracing::warn!(error = %error, "connect credential hydration unavailable");
3461 for platform in &mut config.connect.platforms {
3462 platform.token = None;
3463 platform.app_secret = None;
3464 }
3465 }
3466 }
3467 if provider_mcp_ready {
3468 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3469 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3470 config.clear_access_control_runtime_verifiers();
3471 }
3472 } else {
3473 config.clear_access_control_runtime_verifiers();
3474 }
3475 config.normalize_tool_settings();
3476 config.normalize_skill_settings();
3477 config.normalize_plugin_trust_settings();
3478
3479 config.extra.remove("data_dir");
3482
3483 if apply_env {
3487 config.apply_env_overrides();
3488 }
3489
3490 if publish {
3494 config.publish_env_vars();
3495 }
3496
3497 config
3498 }
3499
3500 fn apply_env_overrides(&mut self) {
3504 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3505 if let Ok(port) = port.parse() {
3506 self.server.port = port;
3507 }
3508 }
3509
3510 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3511 self.server.bind = bind;
3512 }
3513
3514 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
3516 self.provider = provider;
3517 }
3518
3519 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
3520 self.headless_auth = parse_bool_env(&headless);
3521 }
3522
3523 if let Ok(project_prompt_injection) =
3524 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
3525 {
3526 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3527 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
3528 }
3529
3530 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
3531 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3532 memory.relevant_recall = parse_bool_env(&relevant_recall);
3533 }
3534
3535 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
3536 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3537 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
3538 }
3539
3540 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
3541 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3542 memory.project_first_dream = parse_bool_env(&project_first_dream);
3543 }
3544
3545 if let Ok(key) = std::env::var("BAMBOO_OPENAI_API_KEY") {
3552 let key = key.trim();
3553 if !key.is_empty() {
3554 let openai = self
3555 .providers
3556 .openai
3557 .get_or_insert_with(OpenAIConfig::default);
3558 openai.api_key = key.to_string();
3559 openai.api_key_from_env = true;
3560 }
3561 }
3562 if let Ok(key) = std::env::var("BAMBOO_ANTHROPIC_API_KEY") {
3563 let key = key.trim();
3564 if !key.is_empty() {
3565 let anthropic = self
3566 .providers
3567 .anthropic
3568 .get_or_insert_with(AnthropicConfig::default);
3569 anthropic.api_key = key.to_string();
3570 anthropic.api_key_from_env = true;
3571 }
3572 }
3573 if let Ok(key) = std::env::var("BAMBOO_GEMINI_API_KEY") {
3574 let key = key.trim();
3575 if !key.is_empty() {
3576 let gemini = self
3577 .providers
3578 .gemini
3579 .get_or_insert_with(GeminiConfig::default);
3580 gemini.api_key = key.to_string();
3581 gemini.api_key_from_env = true;
3582 }
3583 }
3584 }
3585
3586 pub fn apply_runtime_env_overrides(&mut self) {
3591 self.apply_env_overrides();
3592 }
3593
3594 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
3600 Self::from_data_dir_impl(data_dir, true, true)
3601 }
3602
3603 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
3607 Self::from_data_dir_impl(data_dir, false, true)
3608 }
3609
3610 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
3617 Self::from_data_dir_impl(data_dir, false, false)
3618 }
3619
3620 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
3648 let connect_path = data_dir.join("connect.json");
3649 match std::fs::read_to_string(&connect_path) {
3650 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
3651 Ok(connect) => {
3652 let legacy_key_present = !connect_config_is_empty(&self.connect);
3653 if legacy_key_present {
3654 tracing::warn!(
3655 "config.json still has a legacy `connect` key alongside \
3656 connect.json; connect.json takes precedence — dropping the \
3657 stale key from config.json now"
3658 );
3659 }
3660 self.connect = connect;
3661 if legacy_key_present {
3662 strip_legacy_connect_key_from_config_json(data_dir);
3663 }
3664 }
3665 Err(e) => {
3666 tracing::error!(
3667 "Failed to parse {:?} ({}); continuing with an empty (inert) \
3668 connect config instead of falling back to a legacy config.json copy",
3669 connect_path,
3670 e
3671 );
3672 quarantine_corrupt_connect(&connect_path);
3673 self.connect = ConnectConfig::default();
3674 }
3675 },
3676 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
3677 if !connect_config_is_empty(&self.connect) {
3678 tracing::info!(
3679 "Migrating legacy `connect` config from config.json to a \
3680 standalone connect.json"
3681 );
3682 strip_legacy_connect_key_from_config_json(data_dir);
3686 if let Err(e) = save_connect_config(&self.connect, data_dir) {
3687 tracing::error!("Failed to write connect.json during migration: {}", e);
3688 }
3689 }
3690 }
3691 Err(e) => {
3692 tracing::error!(
3693 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
3694 connect_path,
3695 e
3696 );
3697 self.connect = ConnectConfig::default();
3698 }
3699 }
3700 }
3701
3702 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
3705 serde_json::from_str::<Config>(content).map(|mut config| {
3706 config.hydrate_proxy_auth_from_encrypted();
3707 config.hydrate_provider_api_keys_from_encrypted();
3708 config.hydrate_provider_instance_api_keys_from_encrypted();
3709 config.hydrate_mcp_secrets_from_encrypted();
3710 config.hydrate_env_vars_from_encrypted();
3711 config.hydrate_cluster_fabric_from_encrypted();
3712 config.hydrate_broker_token_from_encrypted();
3713 config.hydrate_notifications_from_encrypted();
3714 config.hydrate_connect_platform_tokens_from_encrypted();
3715 config.normalize_tool_settings();
3716 config.normalize_skill_settings();
3717 config
3718 })
3719 }
3720
3721 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
3727 let config_path = data_dir.join("config.json");
3728 for gen in 0..BAK_GENERATIONS {
3729 let backup = backup_path_for(&config_path, gen);
3730 let Ok(content) = std::fs::read_to_string(&backup) else {
3731 continue;
3732 };
3733 match Self::parse_and_hydrate(&content) {
3734 Ok(config) => {
3735 tracing::info!("Recovered configuration from {:?}", backup);
3736 return Some((config, gen));
3737 }
3738 Err(e) => {
3739 tracing::warn!(
3740 "Backup {:?} is unparseable ({}); trying an older generation",
3741 backup,
3742 e
3743 );
3744 }
3745 }
3746 }
3747 None
3748 }
3749
3750 const SALVAGE_MAX_KEYS: usize = 512;
3756
3757 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
3784 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
3787 let corrupt_obj = corrupt.as_object()?;
3788 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
3789 tracing::warn!(
3790 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
3791 corrupt_obj.len(),
3792 Self::SALVAGE_MAX_KEYS
3793 );
3794 return None;
3795 }
3796
3797 let mut base = Self::load_backup(data_dir)
3801 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
3802 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
3803 let base_obj = base.as_object_mut()?;
3804
3805 let mut salvaged: Vec<String> = Vec::new();
3806 for (key, value) in corrupt_obj {
3807 let previous = base_obj.insert(key.clone(), value.clone());
3808 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
3812 salvaged.push(key.clone());
3813 } else {
3814 match previous {
3815 Some(prev) => {
3816 base_obj.insert(key.clone(), prev);
3817 }
3818 None => {
3819 base_obj.remove(key);
3820 }
3821 }
3822 }
3823 }
3824
3825 tracing::warn!(
3826 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
3827 last-known-good/default value",
3828 salvaged.len(),
3829 salvaged.join(", ")
3830 );
3831
3832 let rebuilt = serde_json::to_string(&base).ok()?;
3836 Self::parse_and_hydrate(&rebuilt)
3837 .ok()
3838 .map(|config| (config, salvaged))
3839 }
3840
3841 pub fn get_model(&self) -> Option<String> {
3849 if self.features.provider_model_ref {
3850 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
3851 return Some(model_ref.model.clone());
3852 }
3853 }
3854 match self.provider.as_str() {
3855 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
3856 "anthropic" => self
3857 .providers
3858 .anthropic
3859 .as_ref()
3860 .and_then(|c| c.model.clone()),
3861 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
3862 "copilot" => Some(
3863 self.providers
3864 .copilot
3865 .as_ref()
3866 .and_then(|c| c.model.clone())
3867 .unwrap_or_else(|| "gpt-4o".to_string()),
3868 ),
3869 _ => None,
3870 }
3871 }
3872
3873 pub fn get_fast_model(&self) -> Option<String> {
3881 if self.features.provider_model_ref {
3882 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
3883 return Some(model_ref.model.clone());
3884 }
3885 }
3886 let fast = match self.provider.as_str() {
3887 "openai" => self
3888 .providers
3889 .openai
3890 .as_ref()
3891 .and_then(|c| c.fast_model.clone()),
3892 "anthropic" => self
3893 .providers
3894 .anthropic
3895 .as_ref()
3896 .and_then(|c| c.fast_model.clone()),
3897 "gemini" => self
3898 .providers
3899 .gemini
3900 .as_ref()
3901 .and_then(|c| c.fast_model.clone()),
3902 "copilot" => self
3903 .providers
3904 .copilot
3905 .as_ref()
3906 .and_then(|c| c.fast_model.clone()),
3907 _ => None,
3908 };
3909 fast.or_else(|| self.get_model())
3910 }
3911
3912 pub fn get_task_summary_model(&self) -> Option<String> {
3920 if self.features.provider_model_ref {
3921 if let Some(model_ref) = self
3922 .defaults
3923 .as_ref()
3924 .and_then(|d| d.task_summary.as_ref())
3925 .or_else(|| {
3926 self.defaults
3927 .as_ref()
3928 .and_then(|d| d.memory_background.as_ref())
3929 })
3930 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
3931 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
3932 {
3933 return Some(model_ref.model.clone());
3934 }
3935 }
3936
3937 self.get_memory_background_model()
3938 .or_else(|| self.get_model())
3939 }
3940
3941 pub fn get_memory_background_model(&self) -> Option<String> {
3953 if self.features.provider_model_ref {
3954 if let Some(model_ref) = self
3955 .defaults
3956 .as_ref()
3957 .and_then(|d| d.memory_background.as_ref())
3958 {
3959 return Some(model_ref.model.clone());
3960 }
3961 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
3962 return Some(model_ref.model.clone());
3963 }
3964 }
3965 let configured = self
3966 .memory
3967 .as_ref()
3968 .and_then(|memory| memory.background_model.as_ref())
3969 .map(|value| value.trim())
3970 .filter(|value| !value.is_empty())
3971 .map(ToString::to_string);
3972 configured.or_else(|| match self.provider.as_str() {
3973 "openai" => self
3974 .providers
3975 .openai
3976 .as_ref()
3977 .and_then(|c| c.fast_model.clone()),
3978 "anthropic" => self
3979 .providers
3980 .anthropic
3981 .as_ref()
3982 .and_then(|c| c.fast_model.clone()),
3983 "gemini" => self
3984 .providers
3985 .gemini
3986 .as_ref()
3987 .and_then(|c| c.fast_model.clone()),
3988 "copilot" => self
3989 .providers
3990 .copilot
3991 .as_ref()
3992 .and_then(|c| c.fast_model.clone()),
3993 _ => None,
3994 })
3995 }
3996
3997 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4005 let raw = self
4006 .default_work_area
4007 .as_ref()
4008 .and_then(|config| config.path.as_ref())
4009 .map(|value| value.trim())
4010 .filter(|value| !value.is_empty())?;
4011
4012 let candidate = expand_user_path(raw);
4013 if candidate.is_absolute() {
4014 let canonical = std::fs::canonicalize(&candidate).ok();
4015 return canonical
4016 .as_ref()
4017 .filter(|path| path.is_dir())
4018 .map(|_| candidate.clone())
4019 .or_else(|| candidate.is_dir().then_some(candidate));
4020 }
4021
4022 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4023 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4024 canonical
4025 .as_ref()
4026 .filter(|path| path.is_dir())
4027 .map(|_| from_bamboo_dir.clone())
4028 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4029 .or_else(|| candidate.is_dir().then_some(candidate))
4030 }
4031
4032 pub fn get_vision_model(&self) -> Option<String> {
4037 let vision = match self.provider.as_str() {
4038 "openai" => self
4039 .providers
4040 .openai
4041 .as_ref()
4042 .and_then(|c| c.vision_model.clone()),
4043 "anthropic" => self
4044 .providers
4045 .anthropic
4046 .as_ref()
4047 .and_then(|c| c.vision_model.clone()),
4048 "gemini" => self
4049 .providers
4050 .gemini
4051 .as_ref()
4052 .and_then(|c| c.vision_model.clone()),
4053 "copilot" => self
4054 .providers
4055 .copilot
4056 .as_ref()
4057 .and_then(|c| c.vision_model.clone()),
4058 _ => None,
4059 };
4060 vision.or_else(|| self.get_model())
4061 }
4062
4063 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4065 self.reasoning_effort_for_key(&self.provider)
4066 }
4067
4068 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4078 let trimmed = key.trim();
4079 if trimmed.is_empty() {
4080 return None;
4081 }
4082
4083 if let Some(instance) = self.provider_instances.get(trimmed) {
4085 return instance.reasoning_effort;
4086 }
4087
4088 match trimmed {
4090 "openai" => self
4091 .providers
4092 .openai
4093 .as_ref()
4094 .and_then(|c| c.reasoning_effort),
4095 "anthropic" => self
4096 .providers
4097 .anthropic
4098 .as_ref()
4099 .and_then(|c| c.reasoning_effort),
4100 "gemini" => self
4101 .providers
4102 .gemini
4103 .as_ref()
4104 .and_then(|c| c.reasoning_effort),
4105 "copilot" => self
4106 .providers
4107 .copilot
4108 .as_ref()
4109 .and_then(|c| c.reasoning_effort),
4110 "bodhi" => self
4111 .providers
4112 .bodhi
4113 .as_ref()
4114 .and_then(|c| c.reasoning_effort),
4115 _ => None,
4116 }
4117 }
4118
4119 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4121 self.tools
4122 .disabled
4123 .iter()
4124 .map(|name| name.trim())
4125 .filter(|name| !name.is_empty())
4126 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
4127 .collect()
4128 }
4129
4130 pub fn normalize_tool_settings(&mut self) {
4132 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
4133 }
4134
4135 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4137 self.skills
4138 .disabled
4139 .iter()
4140 .map(|id| id.trim())
4141 .filter(|id| !id.is_empty())
4142 .map(|id| id.to_string())
4143 .collect()
4144 }
4145
4146 pub fn normalize_skill_settings(&mut self) {
4148 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4149 }
4150
4151 pub fn normalize_plugin_trust_settings(&mut self) {
4159 self.plugin_trust.trusted_hosts = self
4160 .plugin_trust
4161 .trusted_hosts
4162 .iter()
4163 .map(|entry| entry.trim().to_ascii_lowercase())
4164 .filter(|entry| !entry.is_empty())
4165 .collect();
4166 }
4167
4168 pub fn effective_default_provider(&self) -> &str {
4173 self.default_provider_instance
4174 .as_deref()
4175 .unwrap_or(&self.provider)
4176 }
4177
4178 pub fn has_provider_instances(&self) -> bool {
4180 !self.provider_instances.is_empty()
4181 }
4182
4183 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4185 self.env_vars
4186 .iter()
4187 .filter(|e| !e.value.trim().is_empty())
4188 .map(|e| (e.name.clone(), e.value.clone()))
4189 .collect()
4190 }
4191
4192 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4193 self.env_vars
4194 .iter()
4195 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4196 .map(|entry| PromptSafeEnvVarEntry {
4197 name: entry.name.clone(),
4198 secret: entry.secret,
4199 description: entry
4200 .description
4201 .as_ref()
4202 .map(|value| value.trim().to_string())
4203 .filter(|value| !value.is_empty()),
4204 })
4205 .collect()
4206 }
4207
4208 pub fn publish_env_vars(&self) {
4210 let map = self.env_vars_as_map();
4211
4212 #[cfg(any(test, feature = "test-utils"))]
4213 if crate::test_support::env_vars_cache_override_is_active() {
4214 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4215 return;
4216 }
4217
4218 let mut env_guard = env_vars_cache().write().recover_poison();
4219 *env_guard = map;
4220
4221 let prompt_safe = self.prompt_safe_env_vars();
4222 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4223 *prompt_guard = prompt_safe;
4224 }
4225
4226 pub fn current_env_vars() -> HashMap<String, String> {
4228 #[cfg(any(test, feature = "test-utils"))]
4229 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4230 return env_vars;
4231 }
4232
4233 env_vars_cache().read().recover_poison().clone()
4234 }
4235
4236 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4238 #[cfg(any(test, feature = "test-utils"))]
4239 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4240 return env_vars;
4241 }
4242
4243 prompt_safe_env_vars_cache().read().recover_poison().clone()
4244 }
4245
4246 fn create_default() -> Self {
4248 Self::from_parts(
4249 ConfigValues {
4250 http_proxy: String::new(),
4251 https_proxy: String::new(),
4252 proxy_auth: None,
4253 proxy_auth_encrypted: None,
4254 proxy_auth_credential_ref: None,
4255 headless_auth: false,
4256 run_budget: RunBudgetConfig::default(),
4257 stream_timeout: StreamTimeoutConfig::default(),
4258 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4259 provider: default_provider(),
4260 provider_instances: HashMap::new(),
4261 default_provider_instance: None,
4262 server: ServerConfig::default(),
4263 keyword_masking: KeywordMaskingConfig::default(),
4264 anthropic_model_mapping: AnthropicModelMapping::default(),
4265 gemini_model_mapping: GeminiModelMapping::default(),
4266 hooks: HooksConfig::default(),
4267 lifecycle_hooks: LifecycleHooksConfig::default(),
4268 tools: ToolsConfig::default(),
4269 skills: SkillsConfig::default(),
4270 env_vars: Vec::new(),
4271 default_work_area: None,
4272 access_control: None,
4273 features: FeatureFlags::default(),
4274 defaults: None,
4275 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4276 notifications: NotificationsConfig::default(),
4277 connect: ConnectConfig::default(),
4278 plugin_trust: PluginTrustConfig::default(),
4279 extra: BTreeMap::new(),
4280 },
4281 None,
4282 SubagentsConfig::default(),
4283 ProviderConfigs::default(),
4284 )
4285 }
4286
4287 pub fn server_addr(&self) -> String {
4289 format!("{}:{}", self.server.bind, self.server.port)
4290 }
4291
4292 pub fn save(&self) -> Result<()> {
4294 self.save_to_dir(default_data_dir())
4295 }
4296
4297 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4299 self.memory.save_sync(data_dir)
4300 }
4301
4302 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4304 self.subagents.save_sync(data_dir)
4305 }
4306
4307 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4310 let mut config = self.clone();
4311 config.refresh_provider_api_keys_encrypted()?;
4312 config.providers.save_sync(data_dir)
4313 }
4314
4315 pub(crate) fn prepare_provider_transaction_documents(
4319 &self,
4320 provider_document: &[u8],
4321 ) -> Result<(Vec<u8>, Vec<u8>)> {
4322 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4323 anyhow::bail!(
4324 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4325 status.source
4326 );
4327 }
4328
4329 let mut to_save = self.clone();
4330 to_save.extra.remove("data_dir");
4331 to_save.extra.remove("model");
4332 to_save.refresh_encrypted_secrets()?;
4333 to_save.ensure_provider_instance_credentials_isolated()?;
4334 to_save.sanitize_mcp_credential_refs_for_disk();
4335 to_save.sanitize_env_vars_for_disk();
4336 to_save.sanitize_notifications_for_disk();
4337 to_save.sanitize_cluster_fabric_for_disk();
4338 to_save.assign_connect_platform_ids();
4339 to_save.normalize_tool_settings();
4340 to_save.normalize_skill_settings();
4341
4342 let mut root = serde_json::to_value(ConfigRoot::from(to_save.values.clone()))
4343 .context("Failed to serialize root config DTO to JSON")?;
4344 if let Some(object) = root.as_object_mut() {
4345 object.remove("connect");
4346 }
4347 let root = serde_json::to_vec_pretty(&root)?;
4348
4349 let mut providers = to_save.providers.0.clone();
4350 macro_rules! sanitize_provider {
4351 ($field:ident) => {
4352 if let Some(provider) = providers.$field.as_mut() {
4353 if !provider.api_key.trim().is_empty()
4354 && !provider.api_key_from_env
4355 && provider.credential_ref.is_none()
4356 {
4357 anyhow::bail!(
4358 "provider secret requires credential transaction before persistence"
4359 );
4360 }
4361 provider.api_key_encrypted = None;
4362 }
4363 };
4364 }
4365 sanitize_provider!(openai);
4366 sanitize_provider!(anthropic);
4367 sanitize_provider!(gemini);
4368 if let Some(provider) = providers.bodhi.as_mut() {
4369 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4370 anyhow::bail!("provider secret requires credential transaction before persistence");
4371 }
4372 provider.api_key_encrypted = None;
4373 }
4374
4375 let existing_provider_value = if provider_document.is_empty() {
4376 None
4377 } else {
4378 Some(
4379 serde_json::from_slice::<Value>(provider_document)
4380 .context("provider metadata document is invalid")?,
4381 )
4382 };
4383 let provider_value = match existing_provider_value {
4384 Some(Value::Object(mut envelope))
4385 if envelope.contains_key("schema_version")
4386 || envelope.contains_key("revision")
4387 || envelope.contains_key("data") =>
4388 {
4389 let revision = envelope
4390 .get("revision")
4391 .and_then(Value::as_u64)
4392 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4393 let schema_version = envelope
4394 .get("schema_version")
4395 .and_then(Value::as_u64)
4396 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4397 if schema_version != 1 || !envelope.contains_key("data") {
4398 anyhow::bail!("provider revision envelope is unsupported");
4399 }
4400 let revision = revision
4401 .checked_add(1)
4402 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4403 envelope.insert("revision".into(), Value::from(revision));
4404 envelope.insert("data".into(), serde_json::to_value(providers)?);
4405 Value::Object(envelope)
4406 }
4407 Some(_) | None => serde_json::to_value(providers)?,
4408 };
4409 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4410 }
4411
4412 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4416 self.recovery_status.as_ref()
4417 }
4418
4419 pub fn confirm_recovery(&mut self) {
4425 if let Some(status) = self.recovery_status.as_mut() {
4426 status.confirmed = true;
4427 }
4428 }
4429
4430 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4438 if self.recovery_status.is_none() {
4439 anyhow::bail!("No pending config-corruption recovery to confirm");
4440 }
4441 self.confirm_recovery();
4442 self.save_to_dir(data_dir)?;
4443 self.recovery_status = None;
4444 Ok(())
4445 }
4446
4447 pub fn assign_connect_platform_ids(&mut self) {
4463 for platform in &mut self.connect.platforms {
4464 if platform.id.is_none() {
4465 platform.id = Some(uuid::Uuid::new_v4().to_string());
4466 }
4467 }
4468 }
4469
4470 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
4482 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4483 anyhow::bail!(
4484 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
4485 has not been confirmed; the corrupt original is preserved at {:?}. Call \
4486 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
4487 status.source,
4488 status.quarantine_path,
4489 );
4490 }
4491 if self.proxy_auth_credential_ref.is_none()
4492 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
4493 {
4494 anyhow::bail!(
4495 "proxy auth requires the isolated credential transaction before persistence"
4496 );
4497 }
4498 if self.env_vars.iter().any(|entry| {
4499 entry.secret
4500 && entry.credential_ref.is_none()
4501 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
4502 }) {
4503 anyhow::bail!(
4504 "secret env vars require the isolated credential transaction before persistence"
4505 );
4506 }
4507 if [
4508 (
4509 &self.notifications.ntfy.token,
4510 &self.notifications.ntfy.token_encrypted,
4511 &self.notifications.ntfy.credential_ref,
4512 ),
4513 (
4514 &self.notifications.bark.device_key,
4515 &self.notifications.bark.device_key_encrypted,
4516 &self.notifications.bark.credential_ref,
4517 ),
4518 ]
4519 .iter()
4520 .any(|(plaintext, ciphertext, reference)| {
4521 reference.is_none()
4522 && (plaintext
4523 .as_deref()
4524 .is_some_and(|value| !value.trim().is_empty())
4525 || ciphertext.is_some())
4526 }) {
4527 anyhow::bail!(
4528 "notification secrets require the isolated credential transaction before persistence"
4529 );
4530 }
4531
4532 if crate::section_layout_is_active(&data_dir)
4533 .context("Failed to inspect modular configuration layout")?
4534 {
4535 crate::persist_facade_effective_config(&data_dir, self)
4536 .context("Failed to persist modular configuration sections")?;
4537 return Ok(());
4538 }
4539
4540 let path = data_dir.join("config.json");
4541
4542 if let Some(parent) = path.parent() {
4543 std::fs::create_dir_all(parent)
4544 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
4545 }
4546
4547 let mut to_save = self.clone();
4548 to_save.extra.remove("data_dir");
4550 to_save.extra.remove("model");
4552 to_save.refresh_encrypted_secrets()?;
4555 to_save.ensure_provider_instance_credentials_isolated()?;
4556 to_save.sanitize_mcp_credential_refs_for_disk();
4557 to_save.sanitize_env_vars_for_disk();
4558 to_save.sanitize_notifications_for_disk();
4559 to_save.sanitize_cluster_fabric_for_disk();
4560 to_save.assign_connect_platform_ids();
4561 to_save.normalize_tool_settings();
4562 to_save.normalize_skill_settings();
4563
4564 let mut config_value = serde_json::to_value(ConfigRoot::from(to_save.values.clone()))
4574 .context("Failed to serialize root config DTO to JSON")?;
4575 if let Some(obj) = config_value.as_object_mut() {
4576 obj.remove("connect");
4577 }
4578 let content = serde_json::to_string_pretty(&config_value)
4579 .context("Failed to serialize config to JSON")?;
4580
4581 to_save.memory.save_sync(&data_dir)?;
4588 to_save.subagents.save_sync(&data_dir)?;
4589 to_save.providers.save_sync(&data_dir)?;
4590
4591 if path.exists()
4598 && std::fs::read_to_string(&path)
4599 .ok()
4600 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
4601 {
4602 rotate_backups(&path, BAK_GENERATIONS);
4606 let backup = backup_path_for(&path, 0);
4607 let backup_result = std::fs::read(&path).and_then(|bytes| {
4608 let mut value: Value = serde_json::from_slice(&bytes)
4609 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
4610 if sanitize_ref_backed_mcp_json(&mut value) {
4611 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
4612 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
4613 })?;
4614 write_atomic(&backup, &sanitized)
4615 } else {
4616 std::fs::copy(&path, &backup).map(|_| ())
4617 }
4618 });
4619 if let Err(e) = backup_result {
4620 tracing::warn!("Failed to back up config.json before save: {}", e);
4621 }
4622 scrub_ref_backed_mcp_from_config_backups(&path);
4623 }
4624
4625 write_atomic(&path, content.as_bytes())
4626 .with_context(|| format!("Failed to write config file: {:?}", path))?;
4627
4628 save_connect_config(&to_save.connect, &data_dir)?;
4629
4630 Ok(())
4631 }
4632}
4633
4634fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
4639 let Some(object) = root.as_object_mut() else {
4640 return false;
4641 };
4642 let Some(mcp) = (if object.contains_key("mcpServers") {
4643 object.get_mut("mcpServers")
4644 } else {
4645 object.get_mut("mcp")
4646 }) else {
4647 return false;
4648 };
4649 let mut changed = false;
4650 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
4651 for server in servers {
4652 if let Some(object) = server.as_object_mut() {
4653 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
4654 {
4655 changed |= sanitize_ref_backed_mcp_transport(transport);
4656 }
4657 }
4658 }
4659 } else if let Some(servers) = mcp.as_object_mut() {
4660 for server in servers.values_mut() {
4661 if let Some(object) = server.as_object_mut() {
4662 changed |= sanitize_ref_backed_mcp_transport(object);
4663 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
4664 {
4665 changed |= sanitize_ref_backed_mcp_transport(transport);
4666 }
4667 }
4668 }
4669 }
4670 changed
4671}
4672
4673fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
4674 let mut changed = false;
4675 let env_names = object
4676 .get("env_credential_refs")
4677 .and_then(Value::as_object)
4678 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
4679 .unwrap_or_default();
4680 for name in env_names {
4681 for field in ["env", "env_encrypted"] {
4682 if object
4683 .get_mut(field)
4684 .and_then(Value::as_object_mut)
4685 .and_then(|values| values.remove(&name))
4686 .is_some()
4687 {
4688 changed = true;
4689 }
4690 }
4691 }
4692 let header_names = object
4693 .get("header_credential_refs")
4694 .and_then(Value::as_object)
4695 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
4696 .unwrap_or_default();
4697 for name in header_names {
4698 for field in ["headers", "headers_encrypted"] {
4699 if object
4700 .get_mut(field)
4701 .and_then(Value::as_object_mut)
4702 .and_then(|values| values.remove(&name))
4703 .is_some()
4704 {
4705 changed = true;
4706 }
4707 }
4708 }
4709 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
4710 for header in headers {
4711 let Some(header) = header.as_object_mut() else {
4712 continue;
4713 };
4714 if header.get("credential_ref").is_some_and(Value::is_string) {
4715 changed |= header.remove("value").is_some();
4716 changed |= header.remove("value_encrypted").is_some();
4717 }
4718 }
4719 }
4720 changed
4721}
4722
4723fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
4724 for generation in 0..BAK_GENERATIONS {
4725 let backup = backup_path_for(path, generation);
4726 let Ok(bytes) = std::fs::read(&backup) else {
4727 continue;
4728 };
4729 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
4730 continue;
4731 };
4732 if sanitize_ref_backed_mcp_json(&mut value) {
4733 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
4734 if let Err(error) = write_atomic(&backup, &sanitized) {
4735 tracing::warn!(
4736 "Failed to scrub MCP credentials from {:?}: {}",
4737 backup,
4738 error
4739 );
4740 }
4741 }
4742 }
4743 }
4744}
4745
4746fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
4756 let path = data_dir.join("connect.json");
4757 if connect_config_is_empty(connect) && !path.exists() {
4758 return Ok(());
4759 }
4760
4761 if path.exists() {
4762 let backup = path.with_extension("json.bak");
4763 if let Err(e) = std::fs::copy(&path, &backup) {
4764 tracing::warn!("Failed to back up connect.json before save: {}", e);
4765 }
4766 }
4767
4768 let content = serde_json::to_string_pretty(connect)
4769 .context("Failed to serialize connect config to JSON")?;
4770 write_atomic(&path, content.as_bytes())
4771 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
4772 Ok(())
4773}
4774
4775fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
4793 let config_path = data_dir.join("config.json");
4794 let content = match std::fs::read_to_string(&config_path) {
4795 Ok(content) => content,
4796 Err(e) => {
4797 tracing::error!(
4798 "Failed to read config.json to strip legacy `connect` key: {}",
4799 e
4800 );
4801 return;
4802 }
4803 };
4804 let mut value: serde_json::Value = match serde_json::from_str(&content) {
4805 Ok(value) => value,
4806 Err(e) => {
4807 tracing::error!(
4808 "Failed to parse config.json to strip legacy `connect` key: {}",
4809 e
4810 );
4811 return;
4812 }
4813 };
4814 let Some(obj) = value.as_object_mut() else {
4815 return;
4816 };
4817 if obj.remove("connect").is_none() {
4818 return;
4821 }
4822 let rewritten = match serde_json::to_string_pretty(&value) {
4823 Ok(rewritten) => rewritten,
4824 Err(e) => {
4825 tracing::error!(
4826 "Failed to serialize config.json after stripping legacy `connect` key: {}",
4827 e
4828 );
4829 return;
4830 }
4831 };
4832 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
4833 tracing::error!(
4834 "Failed to write config.json after stripping legacy `connect` key: {}",
4835 e
4836 );
4837 }
4838}
4839
4840fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
4877 let config_path = data_dir.join("config.json");
4878 for gen in 0..BAK_GENERATIONS {
4879 let backup = backup_path_for(&config_path, gen);
4880 let content = match std::fs::read_to_string(&backup) {
4881 Ok(content) => content,
4882 Err(e) => {
4883 if e.kind() != std::io::ErrorKind::NotFound {
4884 tracing::warn!(
4885 "Failed to read {:?} while scanning for legacy connect data ({}); \
4886 leaving it untouched",
4887 backup,
4888 e
4889 );
4890 }
4891 continue;
4892 }
4893 };
4894 let mut value: serde_json::Value = match serde_json::from_str(&content) {
4895 Ok(value) => value,
4896 Err(e) => {
4897 tracing::warn!(
4898 "Skipping unparsable backup {:?} while scanning for legacy connect data \
4899 ({}); left untouched (never deleted)",
4900 backup,
4901 e
4902 );
4903 continue;
4904 }
4905 };
4906 let Some(obj) = value.as_object_mut() else {
4907 continue;
4910 };
4911 if obj.remove("connect").is_none() {
4912 continue;
4915 }
4916 let rewritten = match serde_json::to_string_pretty(&value) {
4917 Ok(rewritten) => rewritten,
4918 Err(e) => {
4919 tracing::error!(
4920 "Failed to serialize {:?} after stripping legacy connect data: {}",
4921 backup,
4922 e
4923 );
4924 continue;
4925 }
4926 };
4927 match write_atomic(&backup, rewritten.as_bytes()) {
4928 Ok(()) => tracing::info!(
4929 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
4930 backup
4931 ),
4932 Err(e) => tracing::error!(
4933 "Failed to write {:?} after stripping legacy connect data: {}",
4934 backup,
4935 e
4936 ),
4937 }
4938 }
4939}
4940
4941fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
4956 let backup = connect_path.with_extension("json.bak");
4957 match std::fs::rename(connect_path, &backup) {
4958 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
4959 Err(e) => {
4960 tracing::warn!(
4961 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
4962 backup,
4963 e
4964 );
4965 if let Err(e) = std::fs::copy(connect_path, &backup) {
4966 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
4967 return;
4968 }
4969 if let Err(e) = std::fs::remove_file(connect_path) {
4970 tracing::error!(
4971 "Quarantined corrupt connect.json to {:?} but failed to remove the \
4972 original {:?}: {}",
4973 backup,
4974 connect_path,
4975 e
4976 );
4977 }
4978 }
4979 }
4980}
4981
4982const QUARANTINE_KEEP: usize = 5;
4985
4986fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
4995 let nanos = std::time::SystemTime::now()
4996 .duration_since(std::time::UNIX_EPOCH)
4997 .map(|d| d.as_nanos())
4998 .unwrap_or(0);
4999 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5003 let mut dedup = 1u32;
5004 while quarantine.exists() {
5005 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5006 dedup += 1;
5007 }
5008 let result = match std::fs::copy(config_path, &quarantine) {
5009 Ok(_) => {
5010 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5011 Some(quarantine)
5012 }
5013 Err(e) => {
5014 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5015 None
5016 }
5017 };
5018 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5019 result
5020}
5021
5022fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5025 let Some(dir) = config_path.parent() else {
5026 return;
5027 };
5028 let prefix = "config.json.corrupted.";
5029 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5030 Ok(entries) => entries
5031 .filter_map(|e| e.ok())
5032 .map(|e| e.path())
5033 .filter(|p| {
5034 p.file_name()
5035 .and_then(|n| n.to_str())
5036 .is_some_and(|n| n.starts_with(prefix))
5037 })
5038 .collect(),
5039 Err(_) => return,
5040 };
5041 if quarantines.len() <= keep {
5042 return;
5043 }
5044 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5046 let remove = quarantines.len() - keep;
5047 for stale in quarantines.into_iter().take(remove) {
5048 if let Err(e) = std::fs::remove_file(&stale) {
5049 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5050 }
5051 }
5052}
5053
5054const BAK_GENERATIONS: usize = 3;
5057
5058fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5060 if gen == 0 {
5061 config_path.with_extension("json.bak")
5062 } else {
5063 config_path.with_extension(format!("json.bak.{gen}"))
5064 }
5065}
5066
5067fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5073 for gen in (1..generations).rev() {
5074 let from = backup_path_for(config_path, gen - 1);
5075 let to = backup_path_for(config_path, gen);
5076 if from.exists() {
5077 if let Err(e) = std::fs::rename(&from, &to) {
5078 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5079 }
5080 }
5081 }
5082}
5083
5084pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5085 crate::config_store::AtomicFileStore::new(path)
5086 .write_bytes_without_backup(content)
5087 .map_err(|error| match error {
5088 crate::config_store::ConfigStoreError::Io(error) => error,
5089 other => std::io::Error::other(other),
5090 })
5091}
5092
5093#[cfg(test)]
5094mod tests {
5095 use super::*;
5096 use std::ffi::OsString;
5097 use std::path::PathBuf;
5098 use std::sync::Mutex;
5099 use std::time::{SystemTime, UNIX_EPOCH};
5100
5101 #[test]
5102 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5103 let config: Config = serde_json::from_value(serde_json::json!({
5104 "hooks": {
5105 "image_fallback": {"enabled": true, "mode": "placeholder"}
5106 },
5107 "lifecycle_hooks": {
5108 "enabled": true,
5109 "PreToolUse": [{
5110 "matcher": "bash|write_file",
5111 "hooks": [{"type": "command", "command": "guard.sh"}]
5112 }],
5113 "SessionStart": [{
5114 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5115 }]
5116 }
5117 }))
5118 .expect("lifecycle hook config should deserialize");
5119
5120 assert!(config.lifecycle_hooks.enabled);
5121 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5122 assert!(
5123 config.lifecycle_hooks.pre_tool_use[0].enabled,
5124 "legacy groups without an enabled flag remain active"
5125 );
5126 assert_eq!(
5127 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5128 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5129 );
5130 assert_eq!(
5131 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5132 25
5133 );
5134
5135 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5136 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5137 assert_eq!(
5138 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5139 "bash|write_file"
5140 );
5141 assert_eq!(
5142 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5143 "command"
5144 );
5145 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5146 .get("enabled")
5147 .is_none());
5148 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5149 .get("timeout_ms")
5150 .is_none());
5151 assert!(json.get("hooks").is_some());
5152 }
5153
5154 #[test]
5155 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5156 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5157 "type": "script",
5158 "path": ".bamboo/hooks/check.js"
5159 }))
5160 .expect("script lifecycle hook should deserialize");
5161
5162 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5163 assert!(matches!(
5164 handler,
5165 LifecycleHookHandler::Script {
5166 runner: LifecycleScriptRunner::Auto,
5167 ..
5168 }
5169 ));
5170 let json = serde_json::to_value(handler).expect("script hook should serialize");
5171 assert_eq!(json["type"], "script");
5172 assert!(json.get("timeout_ms").is_none());
5173 assert!(json.get("runner").is_none());
5174 }
5175
5176 #[test]
5177 fn script_runner_support_is_extension_aware() {
5178 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5179 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5180 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5181 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5182 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5183 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5184 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5185 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5186 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5187 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5188 }
5189
5190 #[test]
5191 fn script_runner_names_round_trip_through_config_json() {
5192 for (runner, name) in [
5193 (LifecycleScriptRunner::Auto, "auto"),
5194 (LifecycleScriptRunner::Node, "node"),
5195 (LifecycleScriptRunner::Bun, "bun"),
5196 (LifecycleScriptRunner::Python, "python"),
5197 (LifecycleScriptRunner::Bash, "bash"),
5198 (LifecycleScriptRunner::PowerShell, "powershell"),
5199 (LifecycleScriptRunner::Cmd, "cmd"),
5200 ] {
5201 let json = serde_json::to_value(runner).unwrap();
5202 assert_eq!(json, name);
5203 assert_eq!(
5204 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5205 runner
5206 );
5207 }
5208 }
5209
5210 #[test]
5211 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5212 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5213 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5214 let json = serde_json::to_value(&config).unwrap();
5215 assert!(json.get("lifecycle_hooks").is_none());
5216 }
5217
5218 #[test]
5219 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5220 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5221 .expect("legacy config without stream_timeout should load");
5222 let values = ConfigValues::from(root);
5223
5224 assert_eq!(
5225 values.stream_timeout,
5226 StreamTimeoutConfig {
5227 transport_idle_timeout_secs: 120,
5228 first_semantic_timeout_secs: 600,
5229 semantic_idle_timeout_secs: 600,
5230 }
5231 );
5232 values
5233 .stream_timeout
5234 .validate()
5235 .expect("defaults are valid");
5236 }
5237
5238 #[test]
5239 fn stream_timeout_round_trips_through_persistence_dto() {
5240 let values = ConfigValues {
5241 stream_timeout: StreamTimeoutConfig {
5242 transport_idle_timeout_secs: 45,
5243 first_semantic_timeout_secs: 900,
5244 semantic_idle_timeout_secs: 300,
5245 },
5246 ..ConfigValues::default()
5247 };
5248
5249 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5250 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5251 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5252 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5253
5254 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5255 assert_eq!(
5256 ConfigValues::from(root).stream_timeout,
5257 StreamTimeoutConfig {
5258 transport_idle_timeout_secs: 45,
5259 first_semantic_timeout_secs: 900,
5260 semantic_idle_timeout_secs: 300,
5261 }
5262 );
5263 }
5264
5265 #[test]
5266 fn stream_timeout_rejects_zero_and_unbounded_values() {
5267 for (field, invalid) in [
5268 ("transport_idle_timeout_secs", 0),
5269 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
5270 ("semantic_idle_timeout_secs", 0),
5271 ] {
5272 let mut timeout = serde_json::json!({});
5273 timeout[field] = serde_json::json!(invalid);
5274 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
5275 .expect_err("invalid timeout must be rejected");
5276 assert!(error.to_string().contains("stream timeout must be between"));
5277 }
5278 }
5279
5280 struct EnvVarGuard {
5281 key: &'static str,
5282 previous: Option<OsString>,
5283 }
5284
5285 impl EnvVarGuard {
5286 fn set(key: &'static str, value: &str) -> Self {
5287 let previous = std::env::var_os(key);
5288 std::env::set_var(key, value);
5289 Self { key, previous }
5290 }
5291
5292 fn unset(key: &'static str) -> Self {
5293 let previous = std::env::var_os(key);
5294 std::env::remove_var(key);
5295 Self { key, previous }
5296 }
5297 }
5298
5299 impl Drop for EnvVarGuard {
5300 fn drop(&mut self) {
5301 match &self.previous {
5302 Some(value) => std::env::set_var(self.key, value),
5303 None => std::env::remove_var(self.key),
5304 }
5305 }
5306 }
5307
5308 #[test]
5309 fn run_budget_config_merge_is_tighten_only_per_field() {
5310 let config_default = RunBudgetConfig {
5311 max_total_tokens: Some(100_000),
5312 max_tool_calls: Some(500),
5313 max_subagents: Some(10),
5314 };
5315
5316 assert_eq!(
5318 config_default.merged_with_override(None),
5319 config_default,
5320 "no override falls back to the config default entirely"
5321 );
5322
5323 let tighten_one = RunBudgetConfig {
5326 max_total_tokens: Some(5_000),
5327 max_tool_calls: None,
5328 max_subagents: None,
5329 };
5330 let merged = config_default.merged_with_override(Some(&tighten_one));
5331 assert_eq!(merged.max_total_tokens, Some(5_000));
5332 assert_eq!(merged.max_tool_calls, Some(500));
5333 assert_eq!(merged.max_subagents, Some(10));
5334
5335 let loosen_attempt = RunBudgetConfig {
5338 max_total_tokens: Some(999_999_999),
5339 max_tool_calls: Some(10_000),
5340 max_subagents: Some(1_000),
5341 };
5342 assert_eq!(
5343 config_default.merged_with_override(Some(&loosen_attempt)),
5344 config_default,
5345 "looser per-request values must be clamped to the config ceiling"
5346 );
5347
5348 let empty_override = RunBudgetConfig::default();
5352 assert_eq!(
5353 config_default.merged_with_override(Some(&empty_override)),
5354 config_default,
5355 "an all-absent override body keeps every configured ceiling"
5356 );
5357
5358 let unlimited_default = RunBudgetConfig::default();
5362 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
5363 assert_eq!(merged.max_total_tokens, Some(5_000));
5364 assert_eq!(merged.max_tool_calls, None);
5365 assert_eq!(merged.max_subagents, None);
5366 }
5367
5368 #[test]
5369 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
5370 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
5371 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
5372 assert_eq!(RunBudgetConfig::default().max_subagents, None);
5373
5374 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
5375 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
5376 assert_eq!(cfg.max_total_tokens, Some(250_000));
5377 assert_eq!(
5378 cfg.max_tool_calls, None,
5379 "absent field defaults to unlimited"
5380 );
5381 assert_eq!(cfg.max_subagents, Some(3));
5382
5383 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
5387 assert_eq!(empty, "{}");
5388 }
5389
5390 #[test]
5391 fn subagents_config_without_remote_placements_deserializes_empty() {
5392 let json = r#"{ "max_concurrent": 4 }"#;
5395 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5396 assert_eq!(cfg.max_concurrent, Some(4));
5397 assert!(cfg.remote_placements.is_empty());
5398 let back = serde_json::to_string(&cfg).unwrap();
5400 assert!(
5401 !back.contains("remote_placements"),
5402 "empty vec is skipped: {back}"
5403 );
5404 }
5405
5406 #[test]
5407 fn remote_actor_placement_round_trips() {
5408 let json = r#"{
5409 "remote_placements": [
5410 {
5411 "role": "explorer",
5412 "endpoint": "wss://gpu-host:8443",
5413 "token_env": "WORKER_TOKEN",
5414 "ca_cert_file": "/etc/bamboo/worker.pem"
5415 },
5416 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
5417 ]
5418 }"#;
5419 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5420 assert_eq!(cfg.remote_placements.len(), 2);
5421 let p0 = &cfg.remote_placements[0];
5422 assert_eq!(p0.role, "explorer");
5423 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
5424 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
5425 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
5426 let p1 = &cfg.remote_placements[1];
5428 assert_eq!(p1.role, "writer");
5429 assert!(p1.token_env.is_none());
5430 assert!(p1.ca_cert_file.is_none());
5431
5432 let back = serde_json::to_string(&cfg).unwrap();
5433 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5434 assert_eq!(cfg, reparsed, "round-trip is stable");
5435 assert!(!back.contains("\"token_env\":null"));
5436 assert!(!back.contains("\"ca_cert_file\":null"));
5437 }
5438
5439 #[test]
5440 fn subagents_config_without_schedulable_placements_deserializes_empty() {
5441 let json = r#"{ "max_concurrent": 4 }"#;
5444 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5445 assert!(cfg.schedulable_placements.is_empty());
5446 let back = serde_json::to_string(&cfg).unwrap();
5448 assert!(
5449 !back.contains("schedulable_placements"),
5450 "empty vec is skipped: {back}"
5451 );
5452 }
5453
5454 #[test]
5455 fn schedulable_placement_round_trips() {
5456 let json = r#"{
5457 "schedulable_placements": [
5458 {
5459 "role": "explorer",
5460 "pool": "gpu-pool",
5461 "registry_url": "https://control-plane:9562",
5462 "token_env": "WORKER_TOKEN",
5463 "ca_cert_file": "/etc/bamboo/worker.pem"
5464 },
5465 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
5466 ]
5467 }"#;
5468 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5469 assert_eq!(cfg.schedulable_placements.len(), 2);
5470 let p0 = &cfg.schedulable_placements[0];
5471 assert_eq!(p0.role, "explorer");
5472 assert_eq!(p0.pool, "gpu-pool");
5473 assert_eq!(p0.registry_url, "https://control-plane:9562");
5474 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
5475 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
5476 let p1 = &cfg.schedulable_placements[1];
5478 assert_eq!(p1.role, "writer");
5479 assert_eq!(p1.pool, "cpu-pool");
5480 assert!(p1.token_env.is_none());
5481 assert!(p1.ca_cert_file.is_none());
5482
5483 let back = serde_json::to_string(&cfg).unwrap();
5484 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5485 assert_eq!(cfg, reparsed, "round-trip is stable");
5486 assert!(!back.contains("\"token_env\":null"));
5487 assert!(!back.contains("\"ca_cert_file\":null"));
5488 }
5489
5490 #[test]
5491 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
5492 let json = r#"{ "max_concurrent": 4 }"#;
5496 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5497 assert!(cfg.mcp_role_allowlist.is_empty());
5498 let back = serde_json::to_string(&cfg).unwrap();
5500 assert!(
5501 !back.contains("mcp_role_allowlist"),
5502 "empty vec is skipped: {back}"
5503 );
5504 }
5505
5506 #[test]
5507 fn mcp_role_allowlist_entry_round_trips() {
5508 let json = r#"{
5509 "mcp_role_allowlist": [
5510 { "role": "researcher", "tools": ["fetch_url"] },
5511 { "role": "sandboxed", "tools": [] }
5512 ]
5513 }"#;
5514 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5515 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
5516 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
5517 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
5518 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
5521 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
5522
5523 let back = serde_json::to_string(&cfg).unwrap();
5524 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5525 assert_eq!(cfg, reparsed, "round-trip is stable");
5526 }
5527
5528 #[test]
5529 fn server_config_without_tls_field_deserializes_back_compat() {
5530 let server: ServerConfig = serde_json::from_value(serde_json::json!({
5533 "port": 9562,
5534 "bind": "127.0.0.1"
5535 }))
5536 .expect("legacy server config without tls should deserialize");
5537
5538 assert_eq!(server.tls, None);
5539 assert_eq!(server.port, 9562);
5540 assert_eq!(server.bind, "127.0.0.1");
5541 }
5542
5543 #[test]
5544 fn server_config_omits_tls_when_none() {
5545 let server = ServerConfig::default();
5548 let value = serde_json::to_value(&server).expect("server config should serialize");
5549 let obj = value
5550 .as_object()
5551 .expect("server config serializes to object");
5552 assert!(
5553 !obj.contains_key("tls"),
5554 "tls must be omitted when None, got: {value}"
5555 );
5556 }
5557
5558 #[test]
5559 fn server_config_with_tls_roundtrips() {
5560 let server: ServerConfig = serde_json::from_value(serde_json::json!({
5561 "port": 9562,
5562 "bind": "0.0.0.0",
5563 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
5564 }))
5565 .expect("server config with tls should deserialize");
5566
5567 let tls = server.tls.clone().expect("tls should be Some");
5568 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
5569 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
5570
5571 let value = serde_json::to_value(&server).expect("serialize");
5573 assert!(value.as_object().unwrap().contains_key("tls"));
5574 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
5575 assert_eq!(back.tls, server.tls);
5576 }
5577
5578 #[test]
5579 fn access_control_without_devices_field_deserializes_back_compat() {
5580 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
5583 "password_enabled": true,
5584 "password_hash": "deadbeef",
5585 "password_salt": "01020304",
5586 }))
5587 .expect("legacy access_control without devices should deserialize");
5588
5589 assert!(access.devices.is_empty());
5590 assert!(access.password_enabled);
5591 }
5592
5593 #[test]
5594 fn access_control_omits_devices_when_empty() {
5595 let access = AccessControlConfig {
5598 password_enabled: true,
5599 repair_required: false,
5600 password_hash: Some("deadbeef".to_string()),
5601 password_salt: Some("01020304".to_string()),
5602 password_credential_ref: None,
5603 password_configured: false,
5604 updated_at: None,
5605 devices: Vec::new(),
5606 };
5607 let value = serde_json::to_value(&access).expect("serialize");
5608 let obj = value.as_object().expect("object");
5609 assert!(
5610 !obj.contains_key("devices"),
5611 "devices must be omitted when empty, got: {value}"
5612 );
5613 }
5614
5615 #[test]
5616 fn access_control_with_devices_roundtrips() {
5617 let device = DeviceCredential {
5618 device_id: "bamboo_0123456789ab".to_string(),
5619 label: "iPhone 15".to_string(),
5620 token_hash: "abcd".to_string(),
5621 token_salt: "ef01".to_string(),
5622 token_credential_ref: None,
5623 token_configured: false,
5624 created_at: "2026-06-23T00:00:00Z".to_string(),
5625 last_used_at: None,
5626 revoked: false,
5627 };
5628 let access = AccessControlConfig {
5629 password_enabled: true,
5630 repair_required: false,
5631 password_hash: Some("deadbeef".to_string()),
5632 password_salt: Some("01020304".to_string()),
5633 password_credential_ref: None,
5634 password_configured: false,
5635 updated_at: None,
5636 devices: vec![device.clone()],
5637 };
5638 let value = serde_json::to_value(&access).expect("serialize");
5639 assert!(value.as_object().unwrap().contains_key("devices"));
5640 assert!(value["devices"][0].get("token_hash").is_none());
5641 assert!(value["devices"][0].get("token_salt").is_none());
5642 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
5643 assert_eq!(back.devices[0].device_id, device.device_id);
5644 assert!(back.devices[0].token_hash.is_empty());
5645 assert!(back.devices[0].token_salt.is_empty());
5646 }
5647
5648 #[test]
5649 fn reasoning_effort_for_key_resolves_instance_id() {
5650 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5654 "provider_type": "copilot",
5655 "reasoning_effort": "high",
5656 }))
5657 .expect("instance config should deserialize");
5658
5659 let mut config = Config::create_default();
5660 config
5661 .provider_instances
5662 .insert("copilot-work".to_string(), instance);
5663
5664 assert_eq!(
5665 config.reasoning_effort_for_key("copilot-work"),
5666 Some(ReasoningEffort::High),
5667 );
5668 }
5669
5670 #[test]
5671 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
5672 let mut config = Config::create_default();
5674 config.providers.bodhi = Some(
5675 serde_json::from_value(serde_json::json!({
5676 "reasoning_effort": "xhigh",
5677 }))
5678 .expect("bodhi config should deserialize"),
5679 );
5680
5681 assert_eq!(
5682 config.reasoning_effort_for_key("bodhi"),
5683 Some(ReasoningEffort::Xhigh),
5684 );
5685 }
5686
5687 #[test]
5688 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
5689 let mut config = Config::create_default();
5690 config.providers.openai = Some(
5691 serde_json::from_value(serde_json::json!({
5692 "api_key": "sk-test",
5693 "reasoning_effort": "low",
5694 }))
5695 .expect("openai config should deserialize"),
5696 );
5697
5698 assert_eq!(
5699 config.reasoning_effort_for_key("openai"),
5700 Some(ReasoningEffort::Low),
5701 );
5702 }
5703
5704 #[test]
5705 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
5706 let config = Config::create_default();
5707 assert_eq!(config.reasoning_effort_for_key("nope"), None);
5708 assert_eq!(config.reasoning_effort_for_key(" "), None);
5709 }
5710
5711 struct TempHome {
5712 path: PathBuf,
5713 }
5714
5715 impl TempHome {
5716 fn new() -> Self {
5717 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
5729 std::sync::atomic::AtomicU64::new(0);
5730 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5731 let nanos = SystemTime::now()
5732 .duration_since(UNIX_EPOCH)
5733 .expect("clock should be after unix epoch")
5734 .as_nanos();
5735 let path = std::env::temp_dir().join(format!(
5736 "chat-core-config-test-{}-{}-{}",
5737 std::process::id(),
5738 nanos,
5739 unique
5740 ));
5741 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
5742 Self { path }
5743 }
5744
5745 fn set_config_json(&self, content: &str) {
5746 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
5749 std::fs::write(self.path.join("config.json"), content)
5750 .expect("failed to write config.json");
5751 }
5752 }
5753
5754 impl Drop for TempHome {
5755 fn drop(&mut self) {
5756 let _ = std::fs::remove_dir_all(&self.path);
5757 }
5758 }
5759
5760 fn env_lock() -> &'static Mutex<()> {
5764 crate::test_support::env_cache_lock()
5765 }
5766
5767 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
5769 env_lock().lock().unwrap_or_else(|poisoned| {
5770 poisoned.into_inner()
5772 })
5773 }
5774
5775 #[test]
5776 fn parse_bool_env_true_values() {
5777 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
5778 assert!(parse_bool_env(value), "value {value:?} should be true");
5779 }
5780 }
5781
5782 #[test]
5783 fn parse_bool_env_false_values() {
5784 for value in ["0", "false", "no", "off", "", " "] {
5785 assert!(!parse_bool_env(value), "value {value:?} should be false");
5786 }
5787 }
5788
5789 #[test]
5790 fn config_new_ignores_http_proxy_env_vars() {
5791 let _lock = env_lock_acquire();
5792 let temp_home = TempHome::new();
5793 temp_home.set_config_json(
5794 r#"{
5795 "http_proxy": "",
5796 "https_proxy": ""
5797}"#,
5798 );
5799
5800 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
5801 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
5802
5803 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5804
5805 assert!(
5806 config.http_proxy.is_empty(),
5807 "config should ignore HTTP_PROXY env var"
5808 );
5809 assert!(
5810 config.https_proxy.is_empty(),
5811 "config should ignore HTTPS_PROXY env var"
5812 );
5813 }
5814
5815 #[test]
5816 fn config_new_loads_config_when_proxy_fields_omitted() {
5817 let _lock = env_lock_acquire();
5818 let temp_home = TempHome::new();
5819 temp_home.set_config_json(
5820 r#"{
5821 "provider": "openai",
5822 "providers": {
5823 "openai": {
5824 "api_key": "sk-test",
5825 "model": "gpt-4o"
5826 }
5827 }
5828}"#,
5829 );
5830
5831 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
5832 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
5833
5834 let config = Config::from_data_dir(Some(temp_home.path.clone()));
5835
5836 assert_eq!(
5837 config
5838 .providers
5839 .openai
5840 .as_ref()
5841 .and_then(|c| c.model.as_deref()),
5842 Some("gpt-4o"),
5843 "config should load provider model from config file even when proxy fields are omitted"
5844 );
5845 assert!(config.http_proxy.is_empty());
5846 assert!(config.https_proxy.is_empty());
5847 }
5848
5849 #[test]
5850 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
5851 let _lock = crate::test_support::env_cache_lock_acquire();
5852 let mut config = Config::default();
5853 config.env_vars.extend([
5854 EnvVarEntry {
5855 name: "SECRET_TOKEN".to_string(),
5856 value: "top-secret".to_string(),
5857 secret: true,
5858 value_encrypted: None,
5859 credential_ref: None,
5860 configured: true,
5861 description: Some("Service token".to_string()),
5862 },
5863 EnvVarEntry {
5864 name: "API_BASE".to_string(),
5865 value: "https://internal.example".to_string(),
5866 secret: false,
5867 value_encrypted: None,
5868 credential_ref: None,
5869 configured: true,
5870 description: Some("Internal API base".to_string()),
5871 },
5872 ]);
5873
5874 config.publish_env_vars();
5875
5876 let injected = Config::current_env_vars();
5877 assert_eq!(
5878 injected.get("SECRET_TOKEN").map(String::as_str),
5879 Some("top-secret")
5880 );
5881 assert_eq!(
5882 injected.get("API_BASE").map(String::as_str),
5883 Some("https://internal.example")
5884 );
5885
5886 let prompt_safe = Config::current_prompt_safe_env_vars();
5887 assert_eq!(prompt_safe.len(), 2);
5888 assert!(prompt_safe.iter().any(|entry| {
5889 entry.name == "SECRET_TOKEN"
5890 && entry.secret
5891 && entry.description.as_deref() == Some("Service token")
5892 }));
5893 assert!(prompt_safe.iter().any(|entry| {
5894 entry.name == "API_BASE"
5895 && !entry.secret
5896 && entry.description.as_deref() == Some("Internal API base")
5897 }));
5898 assert!(!prompt_safe
5899 .iter()
5900 .any(|entry| entry.name.contains("top-secret")));
5901 assert!(!prompt_safe.iter().any(|entry| {
5902 entry
5903 .description
5904 .as_deref()
5905 .is_some_and(|value| value.contains("https://internal.example"))
5906 }));
5907 }
5908
5909 #[test]
5910 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
5911 let _lock = crate::test_support::env_cache_lock_acquire();
5912
5913 let mut live = Config::default();
5915 live.env_vars.extend([EnvVarEntry {
5916 name: "BAMBOO_CACHE_OWNER_40".to_string(),
5917 value: "live".to_string(),
5918 secret: false,
5919 value_encrypted: None,
5920 credential_ref: None,
5921 configured: true,
5922 description: None,
5923 }]);
5924 live.publish_env_vars();
5925 assert_eq!(
5926 Config::current_env_vars()
5927 .get("BAMBOO_CACHE_OWNER_40")
5928 .map(String::as_str),
5929 Some("live")
5930 );
5931
5932 let temp = TempHome::new();
5934 temp.set_config_json(
5935 &serde_json::json!({
5936 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
5937 })
5938 .to_string(),
5939 );
5940
5941 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5944 assert_eq!(
5945 loaded
5946 .env_vars
5947 .iter()
5948 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
5949 .map(|e| e.value.as_str()),
5950 Some("stale-disk"),
5951 "the returned Config holds the disk value"
5952 );
5953 assert_eq!(
5954 Config::current_env_vars()
5955 .get("BAMBOO_CACHE_OWNER_40")
5956 .map(String::as_str),
5957 Some("live"),
5958 "but the global cache is UNTOUCHED — no clobber (#40)"
5959 );
5960
5961 let _ = Config::from_data_dir(Some(temp.path.clone()));
5963 assert_eq!(
5964 Config::current_env_vars()
5965 .get("BAMBOO_CACHE_OWNER_40")
5966 .map(String::as_str),
5967 Some("stale-disk"),
5968 "the publishing loader clobbers the cache (contrast)"
5969 );
5970 }
5971
5972 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
5973 std::fs::read_dir(dir)
5974 .unwrap()
5975 .filter_map(|e| e.ok())
5976 .any(|e| {
5977 e.file_name()
5978 .to_string_lossy()
5979 .contains("config.json.corrupted.")
5980 })
5981 }
5982
5983 #[test]
5984 fn corrupt_config_recovered_from_backup_and_quarantined() {
5985 let temp = TempHome::new();
5986 std::fs::write(
5988 temp.path.join("config.json.bak"),
5989 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
5990 )
5991 .unwrap();
5992 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
5994
5995 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5996 assert_eq!(
5997 config.http_proxy, "http://from-backup",
5998 "recovered from config.json.bak instead of losing all config"
5999 );
6000 assert!(
6001 dir_has_quarantine_file(&temp.path),
6002 "corrupt config.json was quarantined (preserved), not discarded"
6003 );
6004 }
6005
6006 #[test]
6007 fn corrupt_config_without_backup_quarantines_then_defaults() {
6008 let temp = TempHome::new();
6009 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6010
6011 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6012 assert!(
6013 config.http_proxy.is_empty(),
6014 "no backup -> falls back to defaults"
6015 );
6016 assert!(
6017 dir_has_quarantine_file(&temp.path),
6018 "corrupt config.json is quarantined even when there's no backup"
6019 );
6020 }
6021
6022 #[test]
6023 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6024 let temp = TempHome::new();
6025 temp.set_config_json(
6030 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6031 );
6032
6033 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6034 assert_eq!(
6035 config.http_proxy, "http://salvaged",
6036 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
6037 );
6038 assert!(
6039 config.env_vars.is_empty(),
6040 "the corrupt field reset to its default instead of failing the whole load"
6041 );
6042 assert!(
6043 dir_has_quarantine_file(&temp.path),
6044 "the corrupt config.json was still quarantined for inspection"
6045 );
6046 }
6047
6048 #[test]
6049 fn salvage_preferred_over_backup_for_most_recent_intent() {
6050 let temp = TempHome::new();
6051 std::fs::write(
6053 temp.path.join("config.json.bak"),
6054 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
6055 )
6056 .unwrap();
6057 temp.set_config_json(
6059 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
6060 );
6061
6062 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6063 assert_eq!(
6064 config.http_proxy, "http://new-salvaged",
6065 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
6066 );
6067 }
6068
6069 #[test]
6070 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
6071 let temp = TempHome::new();
6072 std::fs::write(
6074 temp.path.join("config.json.bak"),
6075 serde_json::json!({
6076 "http_proxy": "http://old-from-backup",
6077 "https_proxy": "https://kept-from-backup",
6078 })
6079 .to_string(),
6080 )
6081 .unwrap();
6082 temp.set_config_json(
6085 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
6086 );
6087
6088 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6089 assert_eq!(
6091 config.http_proxy, "http://newer-edit",
6092 "the corrupt file's newer valid edit is applied"
6093 );
6094 assert_eq!(
6096 config.https_proxy, "https://kept-from-backup",
6097 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
6098 );
6099 }
6100
6101 #[test]
6102 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
6103 let temp = TempHome::new();
6104 std::fs::write(
6105 temp.path.join("config.json.bak"),
6106 serde_json::json!({
6107 "providers": {
6108 "anthropic": { "model": "claude-backup" }
6109 },
6110 "memory": {
6111 "auto_dream_enabled": true
6112 },
6113 "subagents": {
6114 "claude_code_model": "claude-code-backup"
6115 }
6116 })
6117 .to_string(),
6118 )
6119 .unwrap();
6120 assert!(!temp.path.join("providers.json").exists());
6121 assert!(!temp.path.join("memory.json").exists());
6122 assert!(!temp.path.join("subagents.json").exists());
6123
6124 let (salvaged, recovered_fields) = Config::salvage_partial(
6125 r#"{"providers":"schema-invalid-provider-section"}"#,
6126 &temp.path,
6127 )
6128 .expect("object-shaped corrupt config should be salvageable");
6129
6130 assert!(
6131 recovered_fields.is_empty(),
6132 "the schema-invalid provider field must not replace the backup baseline"
6133 );
6134 assert_eq!(
6135 salvaged
6136 .providers()
6137 .anthropic
6138 .as_ref()
6139 .and_then(|provider| provider.model.as_deref()),
6140 Some("claude-backup")
6141 );
6142 assert!(
6143 salvaged
6144 .memory()
6145 .as_ref()
6146 .expect("backup memory config survives")
6147 .auto_dream_enabled
6148 );
6149 assert_eq!(
6150 salvaged.subagents().claude_code_model.as_deref(),
6151 Some("claude-code-backup")
6152 );
6153 }
6154
6155 #[test]
6156 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
6157 let temp = TempHome::new();
6158 std::fs::write(
6161 temp.path.join("config.json.bak"),
6162 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6163 )
6164 .unwrap();
6165 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6166
6167 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6168 assert_eq!(
6169 config.http_proxy, "http://from-backup",
6170 "garbage (non-object) config skips salvage and recovers from .bak"
6171 );
6172 }
6173
6174 #[test]
6175 fn quarantine_files_are_capped_to_newest_n() {
6176 let temp = TempHome::new();
6177 let config_path = temp.path.join("config.json");
6178 std::fs::write(&config_path, "{}").unwrap();
6179
6180 for _ in 0..(QUARANTINE_KEEP + 3) {
6183 quarantine_corrupt_config(&config_path);
6184 std::thread::sleep(std::time::Duration::from_millis(3));
6185 }
6186
6187 let count = std::fs::read_dir(&temp.path)
6188 .unwrap()
6189 .filter_map(|e| e.ok())
6190 .filter(|e| {
6191 e.file_name()
6192 .to_string_lossy()
6193 .starts_with("config.json.corrupted.")
6194 })
6195 .count();
6196 assert_eq!(
6197 count, QUARANTINE_KEEP,
6198 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
6199 );
6200 }
6201
6202 #[test]
6203 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
6204 let temp = TempHome::new();
6205 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
6207 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
6208 std::fs::write(
6209 temp.path.join("config.json.bak.1"),
6210 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
6211 )
6212 .unwrap();
6213
6214 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6215 assert_eq!(
6216 config.http_proxy, "http://from-gen-1",
6217 "recovered from .bak.1 when both config.json and .bak are corrupt"
6218 );
6219 }
6220
6221 #[test]
6222 fn save_rotates_backup_generations() {
6223 let temp = TempHome::new();
6224 let path = temp.path.join("config.json");
6225 std::fs::write(
6227 &path,
6228 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
6229 )
6230 .unwrap();
6231
6232 let mut cfg = Config::create_default();
6233 cfg.http_proxy = "http://proxy-v2".to_string();
6235 cfg.save_to_dir(temp.path.clone()).unwrap();
6236 cfg.http_proxy = "http://proxy-v3".to_string();
6238 cfg.save_to_dir(temp.path.clone()).unwrap();
6239
6240 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6241 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
6242 assert!(
6243 bak.contains("proxy-v2"),
6244 ".bak holds the previous generation (v2)"
6245 );
6246 assert!(
6247 bak1.contains("proxy-v1"),
6248 ".bak.1 holds the older rotated generation (v1)"
6249 );
6250 }
6251
6252 #[test]
6253 fn save_backs_up_existing_config() {
6254 let temp = TempHome::new();
6255 std::fs::write(
6257 temp.path.join("config.json"),
6258 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
6259 )
6260 .unwrap();
6261
6262 let mut config = Config::create_default();
6263 config.http_proxy = "http://new".to_string();
6264 config
6265 .save_to_dir(temp.path.clone())
6266 .expect("save succeeds");
6267
6268 let backup =
6269 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
6270 assert!(
6271 backup.contains("http://old"),
6272 "config.json.bak holds the PREVIOUS config (last-known-good)"
6273 );
6274 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
6275 assert!(
6276 current.contains("http://new"),
6277 "config.json holds the new config"
6278 );
6279 }
6280
6281 #[test]
6282 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
6283 let temp = TempHome::new();
6284 std::fs::write(
6286 temp.path.join("config.json.bak"),
6287 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
6288 )
6289 .unwrap();
6290 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
6293
6294 let mut config = Config::create_default();
6295 config.http_proxy = "http://new".to_string();
6296 config
6297 .save_to_dir(temp.path.clone())
6298 .expect("save succeeds");
6299
6300 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6302 assert!(
6303 backup.contains("http://good-bak"),
6304 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
6305 );
6306 }
6307
6308 #[test]
6311 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
6312 let temp = TempHome::new();
6313 std::fs::write(
6314 temp.path.join("config.json.bak"),
6315 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6316 )
6317 .unwrap();
6318 let corrupt_bytes = "{ not valid json ";
6319 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
6320
6321 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6322 let status = config
6323 .recovery_status()
6324 .expect("a corrupt load must set a pending recovery status");
6325 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
6326 assert_eq!(
6327 status.source,
6328 ConfigRecoverySource::Backup { generation: 0 },
6329 "recovered from generation-0 (.bak)"
6330 );
6331 let quarantine_path = status
6332 .quarantine_path
6333 .as_ref()
6334 .expect("quarantine copy should have succeeded");
6335 assert_eq!(
6336 std::fs::read_to_string(quarantine_path).unwrap(),
6337 corrupt_bytes,
6338 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
6339 );
6340 assert_eq!(
6341 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6342 corrupt_bytes,
6343 "the original config.json itself is untouched by the load (only copied, not moved)"
6344 );
6345 }
6346
6347 #[test]
6348 fn recovery_status_set_from_salvage_lists_recovered_fields() {
6349 let temp = TempHome::new();
6350 temp.set_config_json(
6351 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6352 );
6353
6354 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6355 let status = config.recovery_status().expect("pending recovery");
6356 assert!(!status.confirmed);
6357 match &status.source {
6358 ConfigRecoverySource::Salvaged { fields } => {
6359 assert!(
6360 fields.iter().any(|f| f == "http_proxy"),
6361 "salvaged fields should list the recovered key: {fields:?}"
6362 );
6363 }
6364 other => panic!("expected Salvaged source, got {other:?}"),
6365 }
6366 }
6367
6368 #[test]
6369 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
6370 let temp = TempHome::new();
6371 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6373
6374 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6375 let status = config.recovery_status().expect("pending recovery");
6376 assert!(!status.confirmed);
6377 assert_eq!(status.source, ConfigRecoverySource::Defaults);
6378 }
6379
6380 #[test]
6381 fn clean_load_never_sets_recovery_status() {
6382 let temp = TempHome::new();
6383 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
6384
6385 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6386 assert!(
6387 config.recovery_status().is_none(),
6388 "a config.json that parses cleanly must never carry a pending recovery status"
6389 );
6390 }
6391
6392 #[test]
6393 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
6394 let temp = TempHome::new();
6395 let corrupt_bytes = "}}} broken";
6396 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
6397
6398 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6399 assert!(config.recovery_status().is_some());
6400
6401 let err = config
6402 .save_to_dir(temp.path.clone())
6403 .expect_err("save must refuse while recovery is unconfirmed");
6404 assert!(
6405 err.to_string().contains("recovered from corruption")
6406 || err.to_string().contains("confirm"),
6407 "error should explain the refused overwrite: {err}"
6408 );
6409
6410 assert_eq!(
6413 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6414 corrupt_bytes,
6415 "a refused save must leave the corrupt original untouched"
6416 );
6417 }
6418
6419 #[test]
6420 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
6421 let temp = TempHome::new();
6422 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
6424 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
6425
6426 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6427 let status = config.recovery_status().expect("pending recovery");
6428 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
6429 assert_eq!(
6430 std::fs::read_to_string(quarantine_path).unwrap(),
6431 truncated,
6432 "truncated original preserved byte for byte in quarantine"
6433 );
6434
6435 let err = config.save_to_dir(temp.path.clone());
6436 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
6437 assert_eq!(
6438 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6439 truncated,
6440 "the half-written original stays exactly as it was after a refused save"
6441 );
6442 }
6443
6444 #[test]
6445 fn confirm_recovery_allows_the_next_save() {
6446 let temp = TempHome::new();
6447 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6448
6449 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6450 assert!(config.recovery_status().is_some());
6451
6452 config.confirm_recovery();
6453 assert!(
6454 config.recovery_status().is_some_and(|s| s.confirmed),
6455 "confirm_recovery flips the flag but keeps the status around"
6456 );
6457
6458 config
6459 .save_to_dir(temp.path.clone())
6460 .expect("save must succeed once the recovery is confirmed");
6461 }
6462
6463 #[test]
6464 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
6465 let temp = TempHome::new();
6466 std::fs::write(
6467 temp.path.join("config.json"),
6468 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
6469 )
6470 .unwrap();
6471
6472 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6473 assert!(config.recovery_status().is_some());
6474 let quarantine_path = config
6475 .recovery_status()
6476 .unwrap()
6477 .quarantine_path
6478 .clone()
6479 .unwrap();
6480
6481 config
6482 .confirm_recovery_and_save_to_dir(temp.path.clone())
6483 .expect("confirm+save should succeed");
6484
6485 assert!(
6486 config.recovery_status().is_none(),
6487 "the pending flag is cleared once the recovery is confirmed and persisted"
6488 );
6489 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
6490 assert!(
6491 on_disk.contains("http://recovered"),
6492 "config.json now holds the recovered (salvaged) state"
6493 );
6494 assert!(
6497 quarantine_path.exists(),
6498 "the quarantined original survives confirmation — it's never deleted"
6499 );
6500 }
6501
6502 #[test]
6503 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
6504 let temp = TempHome::new();
6505 let mut config = Config::create_default();
6506 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
6507 assert!(
6508 err.is_err(),
6509 "confirming a recovery that was never pending must error, not silently succeed"
6510 );
6511 }
6512
6513 fn connect_platform_with_encrypted(
6516 platform_type: &str,
6517 token_encrypted: &str,
6518 ) -> ConnectPlatformConfig {
6519 ConnectPlatformConfig {
6520 id: None,
6521 project_id: None,
6522 platform_type: platform_type.to_string(),
6523 token: None,
6524 token_encrypted: Some(token_encrypted.to_string()),
6525 token_credential_ref: None,
6526 token_configured: false,
6527 app_id: None,
6528 app_secret: None,
6529 app_secret_encrypted: None,
6530 app_secret_credential_ref: None,
6531 app_secret_configured: false,
6532 domain: None,
6533 allow_from: vec!["user-1".to_string()],
6534 admin_from: Vec::new(),
6535 }
6536 }
6537
6538 fn connect_json_path(temp: &TempHome) -> PathBuf {
6539 temp.path.join("connect.json")
6540 }
6541
6542 #[test]
6543 fn save_splits_connect_into_sibling_connect_json() {
6544 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
6545 let temp = TempHome::new();
6546
6547 let mut config = Config::create_default();
6548 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
6549 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
6550
6551 config
6552 .save_to_dir(temp.path.clone())
6553 .expect("save succeeds");
6554
6555 let config_json: serde_json::Value =
6556 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
6557 .unwrap();
6558 assert!(
6559 config_json.get("connect").is_none(),
6560 "config.json must not carry the `connect` key after a save"
6561 );
6562
6563 let connect_json: serde_json::Value =
6564 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
6565 .unwrap();
6566 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
6567 assert!(
6568 connect_json["platforms"][0]["token_encrypted"]
6569 .as_str()
6570 .is_some_and(|v| !v.is_empty()),
6571 "the token is persisted in its encrypted form in connect.json"
6572 );
6573 assert!(
6574 connect_json["platforms"][0].get("token").is_none(),
6575 "the plaintext token is never persisted (skip_serializing)"
6576 );
6577 }
6578
6579 #[test]
6582 fn save_assigns_a_missing_connect_platform_id() {
6583 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
6584 let temp = TempHome::new();
6585
6586 let mut config = Config::create_default();
6587 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
6588 assert!(
6589 config.connect.platforms[0].id.is_none(),
6590 "precondition: the entry starts without an id"
6591 );
6592
6593 config
6594 .save_to_dir(temp.path.clone())
6595 .expect("save succeeds");
6596
6597 let connect_json: serde_json::Value =
6598 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
6599 .unwrap();
6600 let persisted_id = connect_json["platforms"][0]["id"]
6601 .as_str()
6602 .expect("save_to_dir must backfill a missing id onto the persisted entry");
6603 assert!(!persisted_id.is_empty());
6604
6605 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6606 assert_eq!(
6607 reloaded.connect.platforms[0].id.as_deref(),
6608 Some(persisted_id),
6609 "the assigned id round-trips through a reload"
6610 );
6611 }
6612
6613 #[test]
6614 fn save_never_reassigns_an_existing_connect_platform_id() {
6615 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
6616 let temp = TempHome::new();
6617
6618 let mut config = Config::create_default();
6619 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
6620 platform.id = Some("stable-id-123".to_string());
6621 config.connect.platforms = vec![platform];
6622
6623 config
6624 .save_to_dir(temp.path.clone())
6625 .expect("first save succeeds");
6626 config
6628 .save_to_dir(temp.path.clone())
6629 .expect("second save succeeds");
6630
6631 let connect_json: serde_json::Value =
6632 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
6633 .unwrap();
6634 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
6635 }
6636
6637 #[test]
6638 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
6639 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
6640 let temp = TempHome::new();
6641
6642 let mut config = Config::create_default();
6643 config.connect.platforms = vec![
6644 connect_platform_with_encrypted("telegram", "cipher-a"),
6645 connect_platform_with_encrypted("telegram", "cipher-b"),
6646 ];
6647
6648 config
6649 .save_to_dir(temp.path.clone())
6650 .expect("save succeeds");
6651
6652 let connect_json: serde_json::Value =
6653 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
6654 .unwrap();
6655 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
6656 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
6657 assert_ne!(
6658 id_a, id_b,
6659 "two entries sharing platform_type must still get distinct ids"
6660 );
6661 }
6662
6663 #[test]
6664 fn load_never_assigns_or_persists_an_id_by_itself() {
6665 let temp = TempHome::new();
6666 std::fs::write(
6667 connect_json_path(&temp),
6668 serde_json::json!({
6669 "platforms": [
6670 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
6671 ]
6672 })
6673 .to_string(),
6674 )
6675 .unwrap();
6676 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
6677
6678 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6679
6680 assert!(
6681 config.connect.platforms[0].id.is_none(),
6682 "load alone must not backfill an id in memory"
6683 );
6684 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
6685 assert_eq!(
6686 connect_json_before, connect_json_after,
6687 "load must never rewrite connect.json on disk just to backfill an id (#493)"
6688 );
6689 }
6690
6691 #[test]
6692 fn load_merges_connect_json_into_config() {
6693 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
6694 let temp = TempHome::new();
6695 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
6696 std::fs::write(
6697 connect_json_path(&temp),
6698 serde_json::json!({
6699 "platforms": [
6700 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
6701 ]
6702 })
6703 .to_string(),
6704 )
6705 .unwrap();
6706
6707 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6708 assert_eq!(config.connect.platforms.len(), 1);
6709 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
6710 assert_eq!(
6711 config.connect.platforms[0].token.as_deref(),
6712 Some("connect-secret")
6713 );
6714 assert!(config.connect.platforms[0].token_encrypted.is_none());
6715 }
6716
6717 #[test]
6718 fn load_without_connect_json_yields_empty_inert_connect_config() {
6719 let temp = TempHome::new();
6720 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
6721
6722 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6723 assert!(
6724 config.connect.platforms.is_empty(),
6725 "no connect.json and no legacy key -> empty/inert connect config"
6726 );
6727 assert!(
6728 !connect_json_path(&temp).exists(),
6729 "load must not create connect.json when there is nothing to migrate"
6730 );
6731 }
6732
6733 #[test]
6734 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
6735 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
6736 let temp = TempHome::new();
6737 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
6738 let legacy_cipher_for_assert = legacy_cipher.clone();
6739 temp.set_config_json(
6741 &serde_json::json!({
6742 "http_proxy": "http://keep-me",
6743 "connect": {
6744 "platforms": [
6745 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
6746 ]
6747 }
6748 })
6749 .to_string(),
6750 );
6751
6752 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6753
6754 assert_eq!(config.connect.platforms.len(), 1);
6756 assert_eq!(
6757 config.connect.platforms[0].token.as_deref(),
6758 Some("legacy-connect-secret")
6759 );
6760 assert_eq!(config.http_proxy, "http://keep-me");
6762
6763 assert!(
6765 connect_json_path(&temp).exists(),
6766 "migration proactively creates connect.json"
6767 );
6768 let connect_json: serde_json::Value =
6769 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
6770 .unwrap();
6771 assert_eq!(
6772 connect_json["platforms"][0]["token_encrypted"],
6773 legacy_cipher_for_assert
6774 );
6775
6776 let config_json: serde_json::Value =
6778 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
6779 .unwrap();
6780 assert!(
6781 config_json.get("connect").is_none(),
6782 "config.json is rewritten without the legacy `connect` key"
6783 );
6784 }
6785
6786 #[test]
6794 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
6795 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
6796 let temp = TempHome::new();
6797
6798 let original_api_key_encrypted =
6799 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
6800 temp.set_config_json(
6801 &serde_json::json!({
6802 "providers": {
6803 "openai": {
6804 "api_key_encrypted": original_api_key_encrypted,
6805 }
6806 },
6807 "connect": {
6808 "platforms": [
6809 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
6810 ]
6811 }
6812 })
6813 .to_string(),
6814 );
6815
6816 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6817 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
6818
6819 assert!(
6822 !temp.path.join("config.json.bak").exists(),
6823 "a read-only load migrating a legacy `connect` key must not rotate \
6824 config.json backups"
6825 );
6826
6827 let config_json: serde_json::Value =
6832 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
6833 .unwrap();
6834 assert_eq!(
6835 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
6836 "an unrelated secret's ciphertext must not be touched by the connect \
6837 migration's narrow write"
6838 );
6839 assert!(
6840 config_json.get("connect").is_none(),
6841 "config.json is still rewritten without the legacy `connect` key"
6842 );
6843 }
6844
6845 #[test]
6846 fn both_files_present_connect_json_wins() {
6847 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6848 let temp = TempHome::new();
6849 let stale = crate::encryption::encrypt("stale-secret").unwrap();
6850 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
6851 temp.set_config_json(
6852 &serde_json::json!({
6853 "connect": {
6854 "platforms": [
6855 { "type": "telegram", "token_encrypted": stale }
6856 ]
6857 }
6858 })
6859 .to_string(),
6860 );
6861 std::fs::write(
6862 connect_json_path(&temp),
6863 serde_json::json!({
6864 "platforms": [
6865 { "type": "telegram", "token_encrypted": authoritative }
6866 ]
6867 })
6868 .to_string(),
6869 )
6870 .unwrap();
6871
6872 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6873 assert_eq!(
6874 config.connect.platforms[0].token.as_deref(),
6875 Some("authoritative-secret"),
6876 "connect.json wins over a stale legacy config.json key"
6877 );
6878 }
6879
6880 #[test]
6885 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
6886 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
6887 let temp = TempHome::new();
6888 let stale = crate::encryption::encrypt("stale-secret").unwrap();
6889 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
6890 temp.set_config_json(
6891 &serde_json::json!({
6892 "http_proxy": "http://keep-me",
6893 "connect": {
6894 "platforms": [
6895 { "type": "telegram", "token_encrypted": stale }
6896 ]
6897 }
6898 })
6899 .to_string(),
6900 );
6901 std::fs::write(
6902 connect_json_path(&temp),
6903 serde_json::json!({
6904 "platforms": [
6905 { "type": "telegram", "token_encrypted": authoritative }
6906 ]
6907 })
6908 .to_string(),
6909 )
6910 .unwrap();
6911
6912 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6913 assert_eq!(
6914 config.connect.platforms[0].token.as_deref(),
6915 Some("authoritative-secret")
6916 );
6917 assert_eq!(config.http_proxy, "http://keep-me");
6919
6920 let config_json: serde_json::Value =
6921 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
6922 .unwrap();
6923 assert!(
6924 config_json.get("connect").is_none(),
6925 "the stale legacy `connect` key must be stripped from config.json \
6926 immediately on load, not left for the next natural save"
6927 );
6928 }
6929
6930 #[test]
6938 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
6939 let temp = TempHome::new();
6940 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
6941 std::fs::write(
6942 temp.path.join("config.json.bak"),
6943 serde_json::json!({
6944 "http_proxy": "http://old",
6945 "connect": {
6946 "platforms": [
6947 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
6948 ]
6949 }
6950 })
6951 .to_string(),
6952 )
6953 .unwrap();
6954
6955 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6956 assert!(config.connect.platforms.is_empty());
6959
6960 let bak: serde_json::Value = serde_json::from_str(
6961 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
6962 )
6963 .unwrap();
6964 assert!(
6965 bak.get("connect").is_none(),
6966 "the legacy `connect` key must be stripped from the tainted .bak generation"
6967 );
6968 assert_eq!(
6969 bak["http_proxy"], "http://old",
6970 "every other key in the .bak generation survives the scrub byte-for-byte in content"
6971 );
6972 }
6973
6974 #[test]
6979 fn scrub_reaches_all_rotated_generations() {
6980 let temp = TempHome::new();
6981 temp.set_config_json(&serde_json::json!({}).to_string());
6982 for (gen_suffix, cipher) in [
6983 ("config.json.bak", "cipher-gen0"),
6984 ("config.json.bak.1", "cipher-gen1"),
6985 ("config.json.bak.2", "cipher-gen2"),
6986 ] {
6987 std::fs::write(
6988 temp.path.join(gen_suffix),
6989 serde_json::json!({
6990 "connect": {
6991 "platforms": [
6992 { "type": "telegram", "token_encrypted": cipher }
6993 ]
6994 }
6995 })
6996 .to_string(),
6997 )
6998 .unwrap();
6999 }
7000
7001 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7002
7003 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7004 let value: serde_json::Value =
7005 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7006 .unwrap();
7007 assert!(
7008 value.get("connect").is_none(),
7009 "{gen_suffix} must have its legacy `connect` key stripped"
7010 );
7011 }
7012 }
7013
7014 #[test]
7022 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7023 let temp = TempHome::new();
7024 temp.set_config_json(&serde_json::json!({}).to_string());
7025 let bak_path = temp.path.join("config.json.bak");
7026 std::fs::write(
7027 &bak_path,
7028 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7029 )
7030 .unwrap();
7031
7032 let before_bytes = std::fs::read(&bak_path).unwrap();
7033 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7034
7035 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7039
7040 let after_bytes = std::fs::read(&bak_path).unwrap();
7041 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7042 assert_eq!(
7043 before_bytes, after_bytes,
7044 "a .bak generation without a legacy `connect` key must not be rewritten at all"
7045 );
7046 assert_eq!(
7047 before_mtime, after_mtime,
7048 "no write means no mtime change either"
7049 );
7050 }
7051
7052 #[test]
7057 fn scrub_skips_unparsable_backup_without_deleting_it() {
7058 let temp = TempHome::new();
7059 temp.set_config_json(&serde_json::json!({}).to_string());
7060 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
7061
7062 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7063
7064 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7065 assert_eq!(
7066 content, "{ not valid json",
7067 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
7068 );
7069 }
7070
7071 #[test]
7075 fn scrub_tolerates_missing_generations() {
7076 let temp = TempHome::new();
7077 temp.set_config_json(&serde_json::json!({}).to_string());
7078 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7080 assert!(config.connect.platforms.is_empty());
7081 assert!(!temp.path.join("config.json.bak").exists());
7082 }
7083
7084 #[test]
7087 fn scrub_does_not_break_backup_rotation() {
7088 let temp = TempHome::new();
7089 std::fs::write(
7090 temp.path.join("config.json"),
7091 serde_json::json!({
7092 "http_proxy": "http://proxy-v1",
7093 "connect": {
7094 "platforms": [
7095 { "type": "telegram", "token_encrypted": "legacy-cipher" }
7096 ]
7097 }
7098 })
7099 .to_string(),
7100 )
7101 .unwrap();
7102 std::fs::write(
7103 temp.path.join("config.json.bak"),
7104 serde_json::json!({
7105 "http_proxy": "http://proxy-v0",
7106 "connect": {
7107 "platforms": [
7108 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
7109 ]
7110 }
7111 })
7112 .to_string(),
7113 )
7114 .unwrap();
7115
7116 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7118 let bak: serde_json::Value = serde_json::from_str(
7119 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7120 )
7121 .unwrap();
7122 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
7123
7124 config.http_proxy = "http://proxy-v2".to_string();
7127 config.save_to_dir(temp.path.clone()).unwrap();
7128
7129 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7130 assert!(
7131 new_bak.contains("proxy-v1"),
7132 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
7133 );
7134 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7135 assert!(
7136 new_bak1.contains("proxy-v0"),
7137 ".bak.1 holds the scrubbed older generation after rotation"
7138 );
7139 assert!(
7140 !new_bak1.contains("legacy-bak-cipher"),
7141 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
7142 stripped secret"
7143 );
7144 }
7145
7146 #[test]
7147 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
7148 let temp = TempHome::new();
7149 temp.set_config_json("{}");
7150 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7151
7152 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7153 assert!(
7154 config.connect.platforms.is_empty(),
7155 "corrupt connect.json fails SAFE to an empty/inert connect config"
7156 );
7157
7158 let backup = connect_json_path(&temp).with_extension("json.bak");
7159 assert!(
7160 backup.exists(),
7161 "the corrupt connect.json is quarantined to connect.json.bak"
7162 );
7163 assert!(
7164 std::fs::read_to_string(backup)
7165 .unwrap()
7166 .contains("not valid json"),
7167 "the quarantined copy holds the bad content"
7168 );
7169 assert!(
7174 !connect_json_path(&temp).exists(),
7175 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
7176 connect.json should remain after quarantine"
7177 );
7178 }
7179
7180 #[test]
7181 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
7182 let temp = TempHome::new();
7183 temp.set_config_json(
7186 &serde_json::json!({
7187 "connect": {
7188 "platforms": [
7189 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
7190 ]
7191 }
7192 })
7193 .to_string(),
7194 );
7195 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7196
7197 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7198 assert!(
7199 config.connect.platforms.is_empty(),
7200 "corrupt connect.json must not fall back to the legacy config.json copy"
7201 );
7202 }
7203
7204 #[test]
7205 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
7206 let temp = TempHome::new();
7207 let config = Config::create_default();
7208 assert!(config.connect.platforms.is_empty());
7209
7210 config
7211 .save_to_dir(temp.path.clone())
7212 .expect("save succeeds");
7213
7214 assert!(
7215 !connect_json_path(&temp).exists(),
7216 "an empty connect config with no pre-existing file must not create one"
7217 );
7218 }
7219
7220 #[test]
7221 fn connect_json_backed_up_before_overwrite() {
7222 let temp = TempHome::new();
7223 std::fs::write(
7224 connect_json_path(&temp),
7225 serde_json::json!({
7226 "platforms": [
7227 { "type": "telegram", "token_encrypted": "old-cipher" }
7228 ]
7229 })
7230 .to_string(),
7231 )
7232 .unwrap();
7233
7234 let mut config = Config::create_default();
7235 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
7236 config
7237 .save_to_dir(temp.path.clone())
7238 .expect("save succeeds");
7239
7240 let backup = connect_json_path(&temp).with_extension("json.bak");
7241 assert!(
7242 std::fs::read_to_string(backup)
7243 .unwrap()
7244 .contains("old-cipher"),
7245 "the previous connect.json is preserved as connect.json.bak before the overwrite"
7246 );
7247 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7248 assert!(current.contains("new-cipher"));
7249 }
7250
7251 #[test]
7254 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
7255 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7256 let temp = TempHome::new();
7257
7258 let mut config = Config::create_default();
7259 config.connect.platforms = vec![ConnectPlatformConfig {
7260 id: None,
7261 project_id: None,
7262 platform_type: "feishu".to_string(),
7263 token: None,
7264 token_encrypted: None,
7265 token_credential_ref: None,
7266 token_configured: false,
7267 app_id: Some("cli_real_app_id".to_string()),
7268 app_secret: Some("plain-app-secret".to_string()),
7269 app_secret_encrypted: None,
7270 app_secret_credential_ref: None,
7271 app_secret_configured: false,
7272 domain: Some("lark".to_string()),
7273 allow_from: vec!["ou_1".to_string()],
7274 admin_from: Vec::new(),
7275 }];
7276
7277 config
7278 .save_to_dir(temp.path.clone())
7279 .expect("save succeeds");
7280
7281 let connect_json: serde_json::Value =
7282 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7283 .unwrap();
7284 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
7285 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
7286 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
7287 assert!(
7288 connect_json["platforms"][0]["app_secret_encrypted"]
7289 .as_str()
7290 .is_some_and(|v| !v.is_empty()),
7291 "app_secret is persisted in its encrypted form in connect.json"
7292 );
7293 assert!(
7294 connect_json["platforms"][0].get("app_secret").is_none(),
7295 "the plaintext app_secret is never persisted (skip_serializing)"
7296 );
7297 }
7298
7299 #[test]
7300 fn load_hydrates_feishu_app_secret_from_encrypted() {
7301 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7302 let temp = TempHome::new();
7303
7304 let mut config = Config::create_default();
7305 config.connect.platforms = vec![ConnectPlatformConfig {
7306 id: None,
7307 project_id: None,
7308 platform_type: "feishu".to_string(),
7309 token: None,
7310 token_encrypted: None,
7311 token_credential_ref: None,
7312 token_configured: false,
7313 app_id: Some("cli_real_app_id".to_string()),
7314 app_secret: Some("plain-app-secret".to_string()),
7315 app_secret_encrypted: None,
7316 app_secret_credential_ref: None,
7317 app_secret_configured: false,
7318 domain: Some("lark".to_string()),
7319 allow_from: vec!["ou_1".to_string()],
7320 admin_from: Vec::new(),
7321 }];
7322 config
7323 .save_to_dir(temp.path.clone())
7324 .expect("save succeeds");
7325
7326 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7327 assert_eq!(reloaded.connect.platforms.len(), 1);
7328 assert_eq!(
7329 reloaded.connect.platforms[0].app_secret.as_deref(),
7330 Some("plain-app-secret"),
7331 "reload hydrates app_secret from app_secret_encrypted"
7332 );
7333 assert_eq!(
7334 reloaded.connect.platforms[0].app_id.as_deref(),
7335 Some("cli_real_app_id")
7336 );
7337 assert_eq!(
7338 reloaded.connect.platforms[0].domain.as_deref(),
7339 Some("lark")
7340 );
7341 }
7342
7343 #[test]
7344 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
7345 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
7346 let temp = TempHome::new();
7347 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
7348 std::fs::write(
7349 connect_json_path(&temp),
7350 serde_json::json!({
7351 "platforms": [
7352 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7353 ]
7354 })
7355 .to_string(),
7356 )
7357 .unwrap();
7358
7359 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7360
7361 assert_eq!(config.connect.platforms.len(), 1);
7362 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7363 assert_eq!(
7364 config.connect.platforms[0].token.as_deref(),
7365 Some("legacy-telegram-secret")
7366 );
7367 assert_eq!(
7368 config.connect.platforms[0].app_id, None,
7369 "a legacy entry with no Feishu fields deserializes them as None"
7370 );
7371 assert_eq!(config.connect.platforms[0].app_secret, None);
7372 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
7373 assert_eq!(config.connect.platforms[0].domain, None);
7374 }
7375
7376 #[test]
7377 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
7378 let _lock = env_lock_acquire();
7379 let temp_home = TempHome::new();
7380 temp_home.set_config_json(
7381 r#"{
7382 "provider": "openai",
7383 "providers": {
7384 "openai": {
7385 "api_key": "sk-test",
7386 "model": "gpt-4o"
7387 }
7388 }
7389}"#,
7390 );
7391
7392 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7393 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7394
7395 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7396
7397 assert_eq!(
7398 config
7399 .providers
7400 .openai
7401 .as_ref()
7402 .and_then(|c| c.model.as_deref()),
7403 Some("gpt-4o")
7404 );
7405 assert!(
7406 config.http_proxy.is_empty(),
7407 "config should keep http_proxy empty when field is omitted"
7408 );
7409 assert!(
7410 config.https_proxy.is_empty(),
7411 "config should keep https_proxy empty when field is omitted"
7412 );
7413 }
7414
7415 #[test]
7416 fn get_memory_background_model_prefers_memory_specific_override() {
7417 let mut config = Config::default();
7418 config.features.provider_model_ref = false;
7419 config.provider = "openai".to_string();
7420 config.providers.openai = Some(OpenAIConfig {
7421 api_key: "test".to_string(),
7422 api_key_encrypted: None,
7423 credential_ref: None,
7424 base_url: None,
7425 model: Some("gpt-main".to_string()),
7426 fast_model: Some("gpt-fast".to_string()),
7427 vision_model: None,
7428 reasoning_effort: None,
7429 responses_only_models: vec![],
7430 request_overrides: None,
7431 extra: BTreeMap::new(),
7432 api_key_from_env: false,
7433 });
7434 config.memory.0 = Some(MemoryConfig {
7435 background_model: Some("memory-fast".to_string()),
7436 ..MemoryConfig::default()
7437 });
7438
7439 assert_eq!(
7440 config.get_memory_background_model().as_deref(),
7441 Some("memory-fast")
7442 );
7443 }
7444
7445 #[test]
7446 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
7447 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
7452 api_key: api_key.to_string(),
7453 api_key_encrypted: None,
7454 credential_ref: None,
7455 base_url: None,
7456 model: None,
7457 fast_model: None,
7458 vision_model: None,
7459 reasoning_effort: None,
7460 responses_only_models: vec![],
7461 request_overrides: None,
7462 extra: BTreeMap::new(),
7463 api_key_from_env: from_env,
7464 };
7465
7466 let mut current = Config::default();
7468 current.providers.openai = Some(openai("sk-env", true));
7469 let mut merged = Config::default();
7470 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
7472 let got = merged.providers.openai.as_ref().unwrap();
7473 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
7474 assert!(got.api_key_from_env, "env flag restored");
7475
7476 let mut merged = Config::default();
7478 merged.providers.openai = Some(openai("sk-explicit", false));
7479 merged.preserve_env_sourced_provider_keys(¤t);
7480 assert_eq!(
7481 merged.providers.openai.as_ref().unwrap().api_key,
7482 "sk-explicit",
7483 "explicit patch key must win"
7484 );
7485
7486 let mut current_plain = Config::default();
7488 current_plain.providers.openai = Some(openai("sk-plain", false));
7489 let mut merged = Config::default();
7490 merged.providers.openai = Some(openai("", false));
7491 merged.preserve_env_sourced_provider_keys(¤t_plain);
7492 assert!(
7493 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
7494 "non-env key must not be restored by this path"
7495 );
7496 }
7497
7498 #[test]
7499 fn refresh_preserves_ciphertext_when_plaintext_empty() {
7500 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
7504 api_key: api_key.to_string(),
7505 api_key_encrypted: enc.map(str::to_string),
7506 credential_ref: None,
7507 base_url: None,
7508 model: None,
7509 fast_model: None,
7510 vision_model: None,
7511 reasoning_effort: None,
7512 responses_only_models: vec![],
7513 request_overrides: None,
7514 extra: BTreeMap::new(),
7515 api_key_from_env: false,
7516 };
7517
7518 let mut config = Config::default();
7520 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
7521 config
7522 .refresh_provider_api_keys_encrypted()
7523 .expect("refresh");
7524 assert_eq!(
7525 config
7526 .providers
7527 .openai
7528 .as_ref()
7529 .unwrap()
7530 .api_key_encrypted
7531 .as_deref(),
7532 Some("preexisting-ciphertext"),
7533 "existing ciphertext must be preserved when plaintext is empty"
7534 );
7535
7536 let mut config = Config::default();
7538 config.providers.openai = Some(openai("", None));
7539 config
7540 .refresh_provider_api_keys_encrypted()
7541 .expect("refresh");
7542 assert!(
7543 config
7544 .providers
7545 .openai
7546 .as_ref()
7547 .unwrap()
7548 .api_key_encrypted
7549 .is_none(),
7550 "no key + no ciphertext should stay None"
7551 );
7552
7553 let mut config = Config::default();
7555 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
7556 config
7557 .refresh_provider_api_keys_encrypted()
7558 .expect("refresh");
7559 let enc = config
7560 .providers
7561 .openai
7562 .as_ref()
7563 .unwrap()
7564 .api_key_encrypted
7565 .clone()
7566 .expect("ciphertext present");
7567 assert!(
7568 !enc.is_empty() && enc != "stale-ciphertext",
7569 "plaintext re-encrypted"
7570 );
7571 }
7572
7573 #[test]
7574 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
7575 let mut config = Config::default();
7581 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
7582 "provider_type": "openai",
7583 "api_key": "sk-instance-live",
7584 }))
7585 .expect("valid instance");
7586 config
7587 .provider_instances
7588 .insert("work".to_string(), instance);
7589
7590 config.refresh_encrypted_secrets().expect("refresh");
7591 assert!(
7592 config.provider_instances["work"]
7593 .api_key_encrypted
7594 .is_some(),
7595 "live config must hold ciphertext after refresh"
7596 );
7597
7598 let value = serde_json::to_value(&config).expect("serialize");
7600 let mut back: Config = serde_json::from_value(value).expect("deserialize");
7601 assert!(
7602 back.provider_instances["work"].api_key.is_empty(),
7603 "plaintext is skip_serializing"
7604 );
7605 back.hydrate_provider_instance_api_keys_from_encrypted();
7606 assert_eq!(
7607 back.provider_instances["work"].api_key, "sk-instance-live",
7608 "key must be recoverable from the round-tripped ciphertext"
7609 );
7610 }
7611
7612 #[test]
7613 fn get_memory_background_model_falls_back_to_provider_fast_model() {
7614 let mut config = Config::default();
7615 config.features.provider_model_ref = false;
7616 config.provider = "openai".to_string();
7617 config.providers.openai = Some(OpenAIConfig {
7618 api_key: "test".to_string(),
7619 api_key_encrypted: None,
7620 credential_ref: None,
7621 base_url: None,
7622 model: Some("gpt-main".to_string()),
7623 fast_model: Some("gpt-fast".to_string()),
7624 vision_model: None,
7625 reasoning_effort: None,
7626 responses_only_models: vec![],
7627 request_overrides: None,
7628 extra: BTreeMap::new(),
7629 api_key_from_env: false,
7630 });
7631
7632 assert_eq!(
7633 config.get_memory_background_model().as_deref(),
7634 Some("gpt-fast")
7635 );
7636 }
7637
7638 #[test]
7639 fn get_memory_background_model_does_not_fall_back_to_main_model() {
7640 let mut config = Config::default();
7641 config.features.provider_model_ref = false;
7642 config.provider = "openai".to_string();
7643 config.providers.openai = Some(OpenAIConfig {
7644 api_key: "test".to_string(),
7645 api_key_encrypted: None,
7646 credential_ref: None,
7647 base_url: None,
7648 model: Some("gpt-main".to_string()),
7649 fast_model: None,
7650 vision_model: None,
7651 reasoning_effort: None,
7652 responses_only_models: vec![],
7653 request_overrides: None,
7654 extra: BTreeMap::new(),
7655 api_key_from_env: false,
7656 });
7657
7658 assert!(config.get_memory_background_model().is_none());
7659 }
7660
7661 #[test]
7662 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
7663 let legacy = serde_json::json!({
7664 "memory": MemoryConfig {
7665 background_model: Some("dream-fast".to_string()),
7666 summary_target_ratio: 0.20,
7667 summary_safe_window_percent: 80,
7668 auto_dream_enabled: true,
7669 auto_dream_interval_secs: 900,
7670 project_prompt_injection: false,
7671 relevant_recall: false,
7672 relevant_recall_rerank: true,
7673 project_first_dream: false,
7674 ledger_agenda_injection: false,
7675 ledger_gardener_enabled: false,
7676 ledger_gardener_interval_secs: 7_200,
7677 ledger_distillation_enabled: false,
7678 dream_refine_mode: true,
7679 gardener_enabled: true,
7680 gardener_interval_secs: 3_600,
7681 gardener_volume_trigger: 40,
7682 gardener_max_splits_per_run: 4,
7683 gardener_min_sections: 7,
7684 dedup_gardener_enabled: true,
7685 dedup_gardener_min_score: 0.7,
7686 dedup_gardener_max_merges_per_run: 3,
7687 memory_active_capacity: 500,
7688 capacity_max_archivals_per_run: 10,
7689 granularity_freshness_gardener_enabled: false,
7690 }
7691 });
7692 let config: Config = serde_json::from_value(legacy).unwrap();
7693
7694 let serialized = serde_json::to_value(&config).expect("config should serialize");
7695 assert!(serialized.get("memory").is_some());
7696 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
7697 assert!(round_tripped
7698 .memory()
7699 .as_ref()
7700 .is_some_and(|memory| memory.dream_refine_mode));
7701 let memory = config.memory.as_ref().expect("memory config should exist");
7702 assert!(memory.auto_dream_enabled);
7703 assert!(!memory.project_prompt_injection);
7704 assert!(!memory.relevant_recall);
7705 assert!(memory.relevant_recall_rerank);
7706 assert!(!memory.project_first_dream);
7707 assert!(memory.dream_refine_mode);
7708 assert!(memory.gardener_enabled);
7709 assert_eq!(memory.gardener_interval_secs, 3_600);
7710 assert_eq!(memory.gardener_volume_trigger, 40);
7711 assert_eq!(memory.gardener_max_splits_per_run, 4);
7712 assert_eq!(memory.gardener_min_sections, 7);
7713 assert!(memory.dedup_gardener_enabled);
7714 assert_eq!(memory.dedup_gardener_min_score, 0.7);
7715 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
7716 assert_eq!(memory.memory_active_capacity, 500);
7717 assert_eq!(memory.capacity_max_archivals_per_run, 10);
7718 assert!(!memory.granularity_freshness_gardener_enabled);
7719 }
7720
7721 #[test]
7723 fn memory_active_capacity_defaults_off() {
7724 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
7725 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
7726 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
7727 let memory = parsed.memory.as_ref().unwrap();
7728 assert_eq!(memory.memory_active_capacity, 0);
7729 assert_eq!(
7730 memory.capacity_max_archivals_per_run, 50,
7731 "omitted field takes the serde default fn"
7732 );
7733 }
7734
7735 #[test]
7736 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
7737 let defaults = MemoryConfig::default();
7738 assert_eq!(defaults.summary_target_ratio, 0.20);
7739 assert_eq!(defaults.summary_safe_window_percent, 80);
7740
7741 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
7742 let memory = parsed.memory.as_ref().expect("memory present");
7743 assert_eq!(memory.summary_target_ratio, 0.20);
7744 assert_eq!(memory.summary_safe_window_percent, 80);
7745 }
7746
7747 #[test]
7751 fn memory_maintenance_integrators_default_on() {
7752 let defaults = MemoryConfig::default();
7753 assert!(defaults.auto_dream_enabled);
7754 assert!(defaults.gardener_enabled);
7755 assert!(defaults.dedup_gardener_enabled);
7756 assert_eq!(defaults.gardener_volume_trigger, 25);
7757
7758 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
7760 let memory = parsed.memory.as_ref().expect("memory present");
7761 assert!(
7762 memory.auto_dream_enabled,
7763 "auto_dream on when field omitted"
7764 );
7765 assert!(memory.gardener_enabled, "gardener on when field omitted");
7766 assert!(
7767 memory.dedup_gardener_enabled,
7768 "dedup gardener on when field omitted"
7769 );
7770 let opted_out: Config =
7772 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
7773 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
7774 }
7775
7776 #[test]
7777 fn memory_config_env_overrides_prompt_flags() {
7778 let _lock = env_lock_acquire();
7779 let temp_home = TempHome::new();
7780 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
7781 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
7782 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
7783 let _relevant_recall_rerank =
7784 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
7785 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
7786
7787 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7788 let memory = config
7789 .memory
7790 .as_ref()
7791 .expect("memory config should be created by env overrides");
7792 assert!(!memory.project_prompt_injection);
7793 assert!(!memory.relevant_recall);
7794 assert!(memory.relevant_recall_rerank);
7795 assert!(!memory.project_first_dream);
7796 }
7797
7798 #[test]
7799 fn provider_api_keys_injected_from_env_and_never_persisted() {
7800 let _lock = env_lock_acquire();
7801 let temp_home = TempHome::new();
7802 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
7803 let _anthropic = EnvVarGuard::set("BAMBOO_ANTHROPIC_API_KEY", "sk-ant-from-env");
7804 let _openai = EnvVarGuard::set("BAMBOO_OPENAI_API_KEY", "sk-oai-from-env");
7805
7806 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7809 assert_eq!(
7810 config
7811 .providers
7812 .anthropic
7813 .as_ref()
7814 .expect("anthropic created from env")
7815 .api_key,
7816 "sk-ant-from-env"
7817 );
7818 assert_eq!(
7819 config
7820 .providers
7821 .openai
7822 .as_ref()
7823 .expect("openai created from env")
7824 .api_key,
7825 "sk-oai-from-env"
7826 );
7827 assert!(config.providers.gemini.is_none());
7829
7830 config
7836 .save_to_dir(temp_home.path.clone())
7837 .expect("save config");
7838 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
7839 .expect("read persisted config.json");
7840 assert!(
7841 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
7842 "env key must not be persisted as plaintext"
7843 );
7844 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
7845 assert!(
7846 disk_json["providers"]["anthropic"]
7847 .get("api_key_encrypted")
7848 .is_none(),
7849 "env-sourced anthropic key must not be re-encrypted into config.json"
7850 );
7851 assert!(
7852 disk_json["providers"]["openai"]
7853 .get("api_key_encrypted")
7854 .is_none(),
7855 "env-sourced openai key must not be re-encrypted into config.json"
7856 );
7857
7858 drop(_anthropic);
7861 drop(_openai);
7862 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
7863 assert!(reloaded
7864 .providers
7865 .anthropic
7866 .as_ref()
7867 .map(|a| a.api_key.is_empty())
7868 .unwrap_or(true));
7869 }
7870
7871 #[test]
7872 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
7873 let _lock = env_lock_acquire();
7874 let temp_home = TempHome::new();
7875 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
7876 let target = temp_home.path.join("workspace-default");
7877 std::fs::create_dir_all(&target).expect("default work area dir should exist");
7878
7879 let mut config = Config::default();
7880 config.default_work_area.replace(DefaultWorkAreaConfig {
7881 path: Some("~/workspace-default".to_string()),
7882 });
7883
7884 assert_eq!(config.get_default_work_area_path(), Some(target));
7885 }
7886
7887 #[test]
7888 fn get_default_work_area_path_returns_none_for_missing_directory() {
7889 let _lock = env_lock_acquire();
7890 let temp_home = TempHome::new();
7891 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
7892
7893 let mut config = Config::default();
7894 config.default_work_area.replace(DefaultWorkAreaConfig {
7895 path: Some("~/missing-default-work-area".to_string()),
7896 });
7897
7898 assert!(config.get_default_work_area_path().is_none());
7899 }
7900
7901 #[test]
7902 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
7903 let mut config = Config::default();
7904 config.tools.disabled = vec![
7905 " read_file ".to_string(),
7906 "".to_string(),
7907 "read_file".to_string(),
7908 "bash".to_string(),
7909 "default::getCurrentDir".to_string(),
7910 ];
7911
7912 config.normalize_tool_settings();
7913
7914 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
7915 }
7916
7917 #[test]
7918 fn config_load_reads_disabled_tools_as_canonical_names() {
7919 let _lock = env_lock_acquire();
7920 let temp_home = TempHome::new();
7921 temp_home.set_config_json(
7922 r#"{
7923 "tools": {
7924 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
7925 }
7926}"#,
7927 );
7928
7929 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7930 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
7931 assert!(config.disabled_tool_names().contains("Bash"));
7932 assert!(config.disabled_tool_names().contains("Read"));
7933 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
7934 }
7935
7936 #[test]
7937 fn normalize_skill_settings_trims_dedupes_and_sorts() {
7938 let mut config = Config::default();
7939 config.skills.disabled = vec![
7940 " pdf ".to_string(),
7941 "".to_string(),
7942 "pdf".to_string(),
7943 "skill-creator".to_string(),
7944 ];
7945
7946 config.normalize_skill_settings();
7947
7948 assert_eq!(
7949 config.skills.disabled,
7950 vec!["pdf".to_string(), "skill-creator".to_string()]
7951 );
7952 }
7953
7954 #[test]
7955 fn config_load_reads_disabled_skills_as_normalized_ids() {
7956 let _lock = env_lock_acquire();
7957 let temp_home = TempHome::new();
7958 temp_home.set_config_json(
7959 r#"{
7960 "skills": {
7961 "disabled": [" pdf ", "skill-creator", "pdf", ""]
7962 }
7963}"#,
7964 );
7965
7966 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7967 assert_eq!(
7968 config.skills.disabled,
7969 vec!["pdf".to_string(), "skill-creator".to_string()]
7970 );
7971 assert!(config.disabled_skill_ids().contains("pdf"));
7972 assert!(config.disabled_skill_ids().contains("skill-creator"));
7973 }
7974
7975 #[test]
7976 fn test_server_config_defaults() {
7977 let _lock = env_lock_acquire();
7978 let temp_home = TempHome::new();
7979
7980 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7981 assert_eq!(config.server.port, 9562);
7982 assert_eq!(config.server.bind, "127.0.0.1");
7983 assert_eq!(config.server.workers, 10);
7984 assert!(config.server.static_dir.is_none());
7985 }
7986
7987 #[test]
7988 fn test_server_addr() {
7989 let mut config = Config::default();
7990 config.server.port = 9000;
7991 config.server.bind = "0.0.0.0".to_string();
7992 assert_eq!(config.server_addr(), "0.0.0.0:9000");
7993 }
7994
7995 #[test]
7996 fn test_env_var_overrides() {
7997 let _lock = env_lock_acquire();
7998 let temp_home = TempHome::new();
7999
8000 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
8001 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
8002 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
8003
8004 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8005 assert_eq!(config.server.port, 9999);
8006 assert_eq!(config.server.bind, "192.168.1.1");
8007 assert_eq!(config.provider, "openai");
8008 }
8009
8010 #[test]
8011 fn test_config_save_and_load() {
8012 let _lock = env_lock_acquire();
8013 let temp_home = TempHome::new();
8014
8015 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8016 config.server.port = 9000;
8017 config.server.bind = "0.0.0.0".to_string();
8018 config.provider = "anthropic".to_string();
8019
8020 config
8022 .save_to_dir(temp_home.path.clone())
8023 .expect("Failed to save config");
8024
8025 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8027
8028 assert_eq!(loaded.server.port, 9000);
8030 assert_eq!(loaded.server.bind, "0.0.0.0");
8031 assert_eq!(loaded.provider, "anthropic");
8032 }
8033
8034 #[test]
8035 fn modular_root_and_public_serde_preserve_legacy_shape() {
8036 let _lock = env_lock_acquire();
8037 let temp_home = TempHome::new();
8038 let input = serde_json::json!({
8039 "http_proxy": "http://proxy.example",
8040 "provider": "openai",
8041 "mcp": { "servers": [] },
8042 "future_extension": { "enabled": true },
8043 "memory": { "background_model": "memory-model" },
8044 "subagents": { "max_concurrent": 7 },
8045 "providers": { "openai": { "model": "chat-model" } }
8046 });
8047
8048 let config: Config = serde_json::from_value(input).unwrap();
8049 let public = serde_json::to_value(&config).unwrap();
8050 assert_eq!(public["http_proxy"], "http://proxy.example");
8051 assert!(public.get("mcp").is_none());
8052 assert!(public.get("mcpServers").is_some());
8053 assert_eq!(public["future_extension"]["enabled"], true);
8054 assert_eq!(public["memory"]["background_model"], "memory-model");
8055 assert_eq!(public["subagents"]["max_concurrent"], 7);
8056 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
8057
8058 let round_tripped: Config = serde_json::from_value(public).unwrap();
8059 assert_eq!(
8060 round_tripped
8061 .memory()
8062 .as_ref()
8063 .unwrap()
8064 .background_model
8065 .as_deref(),
8066 Some("memory-model")
8067 );
8068 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
8069 assert_eq!(
8070 round_tripped
8071 .providers()
8072 .openai
8073 .as_ref()
8074 .unwrap()
8075 .model
8076 .as_deref(),
8077 Some("chat-model")
8078 );
8079 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
8080
8081 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
8082 let persisted: Value =
8083 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
8084 .unwrap();
8085 assert_eq!(persisted["http_proxy"], "http://proxy.example");
8086 assert!(persisted.get("mcpServers").is_some());
8087 assert_eq!(persisted["future_extension"]["enabled"], true);
8088 assert!(persisted.get("memory").is_none());
8089 assert!(persisted.get("subagents").is_none());
8090 assert!(persisted.get("providers").is_none());
8091 for internal_section_name in [
8092 "network",
8093 "provider_routing",
8094 "model_behavior",
8095 "tooling",
8096 "workspace",
8097 "execution",
8098 "integrations",
8099 "plugin_security",
8100 ] {
8101 assert!(persisted.get(internal_section_name).is_none());
8102 }
8103 }
8104
8105 #[test]
8106 fn config_decrypts_proxy_auth_from_encrypted_field() {
8107 let _lock = env_lock_acquire();
8108 let temp_home = TempHome::new();
8109
8110 let key_guard = crate::encryption::set_test_encryption_key([
8112 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8113 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8114 0x1c, 0x1d, 0x1e, 0x1f,
8115 ]);
8116
8117 let auth = ProxyAuth {
8118 username: "user".to_string(),
8119 password: "pass".to_string(),
8120 };
8121 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
8122 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
8123
8124 temp_home.set_config_json(&format!(
8125 r#"{{
8126 "http_proxy": "http://proxy.example.com:8080",
8127 "proxy_auth_encrypted": "{encrypted}"
8128}}"#
8129 ));
8130 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8131 let loaded_auth = config
8132 .proxy_auth
8133 .as_ref()
8134 .expect("proxy auth should be hydrated");
8135 assert_eq!(loaded_auth.username, "user");
8136 assert_eq!(loaded_auth.password, "pass");
8137 drop(key_guard);
8138 }
8139
8140 #[test]
8141 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
8142 let _lock = env_lock_acquire();
8143 let temp_home = TempHome::new();
8144
8145 let key_guard = crate::encryption::set_test_encryption_key([
8147 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8148 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8149 0x1c, 0x1d, 0x1e, 0x1f,
8150 ]);
8151
8152 let auth = ProxyAuth {
8153 username: "user".to_string(),
8154 password: "pass".to_string(),
8155 };
8156 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
8157 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
8158
8159 temp_home.set_config_json(&format!(
8161 r#"{{
8162 "http_proxy": "http://proxy.example.com:8080",
8163 "http_proxy_auth_encrypted": "{encrypted}",
8164 "https_proxy_auth_encrypted": "{encrypted}"
8165}}"#
8166 ));
8167
8168 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8169 let loaded_auth = config
8170 .proxy_auth
8171 .as_ref()
8172 .expect("proxy auth should be hydrated");
8173 assert_eq!(loaded_auth.username, "user");
8174 assert_eq!(loaded_auth.password, "pass");
8175 drop(key_guard);
8176 }
8177
8178 #[test]
8179 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
8180 let _lock = env_lock_acquire();
8181 let temp_home = TempHome::new();
8182
8183 let key_guard = crate::encryption::set_test_encryption_key([
8185 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8186 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8187 0x1c, 0x1d, 0x1e, 0x1f,
8188 ]);
8189
8190 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8191 config.proxy_auth = Some(ProxyAuth {
8192 username: "user".to_string(),
8193 password: "pass".to_string(),
8194 });
8195 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
8196 assert!(error
8197 .to_string()
8198 .contains("isolated credential transaction"));
8199 let path = temp_home.path.join("config.json");
8200 assert!(
8201 !path.exists(),
8202 "a rejected unisolated secret must not create config.json"
8203 );
8204 drop(key_guard);
8205 }
8206
8207 #[test]
8208 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
8209 let _lock = env_lock_acquire();
8210 let temp_home = TempHome::new();
8211 let mut config = Config::default();
8212 config.env_vars.push(EnvVarEntry {
8213 name: "TOKEN".to_string(),
8214 value: String::new(),
8215 secret: true,
8216 value_encrypted: None,
8217 credential_ref: None,
8218 configured: true,
8219 description: None,
8220 });
8221 let path = temp_home.path.join("config.json");
8222
8223 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
8224 assert!(error
8225 .to_string()
8226 .contains("isolated credential transaction"));
8227 assert!(
8228 !path.exists(),
8229 "a rejected dangling ref must not create config.json"
8230 );
8231
8232 let original = br#"{"preserve":"original"}"#;
8233 std::fs::write(&path, original).unwrap();
8234 config.save_to_dir(temp_home.path.clone()).unwrap_err();
8235 assert_eq!(
8236 std::fs::read(&path).unwrap(),
8237 original,
8238 "a rejected dangling ref must not modify an existing config.json"
8239 );
8240 }
8241
8242 #[test]
8243 fn config_save_persists_provider_reference_and_isolates_plaintext() {
8244 let _lock = env_lock_acquire();
8245 let temp_home = TempHome::new();
8246
8247 let key_guard = crate::encryption::set_test_encryption_key([
8249 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8250 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8251 0x1c, 0x1d, 0x1e, 0x1f,
8252 ]);
8253
8254 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
8255 crate::CredentialStore::open(&temp_home.path)
8256 .replace(
8257 reference.clone(),
8258 "sk-test-provider-key",
8259 crate::CredentialSource::User,
8260 0,
8261 )
8262 .unwrap();
8263 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8264 config.provider = "openai".to_string();
8265 config.providers.openai = Some(OpenAIConfig {
8266 api_key: "sk-test-provider-key".to_string(),
8267 api_key_encrypted: None,
8268 credential_ref: Some(reference),
8269 base_url: None,
8270 model: None,
8271 fast_model: None,
8272 vision_model: None,
8273 reasoning_effort: None,
8274 responses_only_models: vec![],
8275 request_overrides: None,
8276 extra: Default::default(),
8277 api_key_from_env: false,
8278 });
8279
8280 config
8281 .save_to_dir(temp_home.path.clone())
8282 .expect("save should persist provider reference");
8283
8284 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
8285 .expect("read providers.json");
8286 assert!(
8287 !content.contains("api_key_encrypted"),
8288 "providers.json must not store provider ciphertext"
8289 );
8290 assert!(
8291 !content.contains("\"api_key\""),
8292 "providers.json should not store plaintext provider keys"
8293 );
8294
8295 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8296 let openai = loaded
8297 .providers
8298 .openai
8299 .as_ref()
8300 .expect("openai config should be present");
8301 assert_eq!(openai.api_key, "sk-test-provider-key");
8302
8303 drop(key_guard);
8304 }
8305
8306 #[test]
8307 fn config_save_persists_mcp_servers_in_mainstream_format() {
8308 let _lock = env_lock_acquire();
8309 let temp_home = TempHome::new();
8310
8311 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8312
8313 let mut env = std::collections::HashMap::new();
8314 env.insert("TOKEN".to_string(), "supersecret".to_string());
8315
8316 config.mcp.servers = vec![
8317 bamboo_domain::mcp_config::McpServerConfig {
8318 id: "stdio-secret".to_string(),
8319 name: None,
8320 enabled: true,
8321 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
8322 bamboo_domain::mcp_config::StdioConfig {
8323 command: "echo".to_string(),
8324 args: vec![],
8325 cwd: None,
8326 env,
8327 env_encrypted: std::collections::HashMap::new(),
8328 env_credential_refs: std::collections::HashMap::new(),
8329 startup_timeout_ms: 5000,
8330 },
8331 ),
8332 request_timeout_ms: 5000,
8333 healthcheck_interval_ms: 1000,
8334 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
8335 allowed_tools: vec![],
8336 denied_tools: vec![],
8337 },
8338 bamboo_domain::mcp_config::McpServerConfig {
8339 id: "sse-secret".to_string(),
8340 name: None,
8341 enabled: true,
8342 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
8343 bamboo_domain::mcp_config::SseConfig {
8344 url: "http://localhost:8080/sse".to_string(),
8345 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
8346 name: "Authorization".to_string(),
8347 value: "Bearer token123".to_string(),
8348 value_encrypted: None,
8349 credential_ref: None,
8350 }],
8351 connect_timeout_ms: 5000,
8352 },
8353 ),
8354 request_timeout_ms: 5000,
8355 healthcheck_interval_ms: 1000,
8356 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
8357 allowed_tools: vec![],
8358 denied_tools: vec![],
8359 },
8360 ];
8361
8362 config
8363 .save_to_dir(temp_home.path.clone())
8364 .expect("save should persist MCP servers");
8365
8366 let content =
8367 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
8368 assert!(
8369 content.contains("\"mcpServers\""),
8370 "config.json should store MCP servers under the mainstream 'mcpServers' key"
8371 );
8372 assert!(
8373 content.contains("supersecret"),
8374 "config.json should persist MCP stdio env in mainstream format"
8375 );
8376 assert!(
8377 content.contains("Bearer token123"),
8378 "config.json should persist MCP SSE headers in mainstream format"
8379 );
8380 assert!(
8381 !content.contains("\"env_encrypted\""),
8382 "config.json should not persist legacy env_encrypted fields"
8383 );
8384 assert!(
8385 !content.contains("\"value_encrypted\""),
8386 "config.json should not persist legacy value_encrypted fields"
8387 );
8388
8389 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8390 let stdio = loaded
8391 .mcp
8392 .servers
8393 .iter()
8394 .find(|s| s.id == "stdio-secret")
8395 .expect("stdio server should exist");
8396 match &stdio.transport {
8397 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
8398 assert_eq!(
8399 stdio.env.get("TOKEN").map(|s| s.as_str()),
8400 Some("supersecret")
8401 );
8402 }
8403 _ => panic!("Expected stdio transport"),
8404 }
8405
8406 let sse = loaded
8407 .mcp
8408 .servers
8409 .iter()
8410 .find(|s| s.id == "sse-secret")
8411 .expect("sse server should exist");
8412 match &sse.transport {
8413 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
8414 assert_eq!(sse.headers[0].value, "Bearer token123");
8415 }
8416 _ => panic!("Expected SSE transport"),
8417 }
8418 }
8419
8420 #[test]
8421 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
8422 let _lock = env_lock_acquire();
8423 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
8424 let temp_home = TempHome::new();
8425 let env_plaintext = "mcp-root-env-plaintext-597";
8426 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
8427 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
8428 std::fs::write(
8429 temp_home.path.join("config.json"),
8430 serde_json::to_vec_pretty(&serde_json::json!({
8431 "features": {"provider_model_ref": true},
8432 "mcpServers": {
8433 "stdio-root": {
8434 "command": "unused-disabled-command",
8435 "env": {"TOKEN": env_plaintext},
8436 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
8437 "env_credential_refs": {
8438 "TOKEN": "mcp.stdio-root.env_TOKEN"
8439 }
8440 },
8441 "http-root": {
8442 "url": "https://example.test/mcp",
8443 "transport_kind": "streamable_http",
8444 "headers": {"Authorization": header_plaintext},
8445 "header_credential_refs": {
8446 "Authorization": "mcp.http-root.header_Authorization"
8447 }
8448 }
8449 }
8450 }))
8451 .unwrap(),
8452 )
8453 .unwrap();
8454 std::fs::write(
8455 temp_home.path.join("mcp.json"),
8456 serde_json::to_vec_pretty(&serde_json::json!({
8457 "schema_version": 1,
8458 "revision": 7,
8459 "data": {
8460 "stdio-root": {
8461 "command": "unused-disabled-command",
8462 "enabled": false,
8463 "env_encrypted": {"TOKEN": legacy_ciphertext},
8464 "request_timeout_ms": 100,
8465 "healthcheck_interval_ms": 100
8466 },
8467 "http-root": {
8468 "url": "https://example.test/mcp",
8469 "transport_kind": "streamable_http",
8470 "enabled": false,
8471 "headers": {"Authorization": header_plaintext},
8472 "request_timeout_ms": 100,
8473 "healthcheck_interval_ms": 100
8474 }
8475 }
8476 }))
8477 .unwrap(),
8478 )
8479 .unwrap();
8480
8481 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
8482 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
8483 temp_home.path.join("mcp.json"),
8484 1,
8485 )
8486 .load()
8487 .unwrap()
8488 .unwrap();
8489 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
8490 config.mcp = stored.data;
8491 config
8492 .hydrate_mcp_credentials_from_store(&temp_home.path)
8493 .unwrap();
8494
8495 let public = serde_json::to_value(&config.mcp).unwrap();
8496 let rendered_public = public.to_string();
8497 assert!(rendered_public.contains(env_plaintext));
8498 assert!(rendered_public.contains(header_plaintext));
8499 let compatible: bamboo_domain::mcp_config::McpConfig =
8500 serde_json::from_value(public).unwrap();
8501 assert_eq!(compatible.servers.len(), 2);
8502
8503 config.features.provider_model_ref = !config.features.provider_model_ref;
8504 config.save_to_dir(temp_home.path.clone()).unwrap();
8505 config.features.provider_model_ref = !config.features.provider_model_ref;
8506 config.save_to_dir(temp_home.path.clone()).unwrap();
8507
8508 for entry in std::fs::read_dir(&temp_home.path)
8509 .unwrap()
8510 .filter_map(Result::ok)
8511 {
8512 let name = entry.file_name().to_string_lossy().to_string();
8513 if name == "credentials.json" || entry.path().is_dir() {
8514 continue;
8515 }
8516 if name == "config.json"
8517 || name == "mcp.json"
8518 || name.contains(".bak")
8519 || name.starts_with("config-credential-migration")
8520 {
8521 let bytes = std::fs::read(entry.path()).unwrap();
8522 let content = String::from_utf8_lossy(&bytes);
8523 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
8524 assert!(
8525 !content.contains(header_plaintext),
8526 "secret leaked to {name}"
8527 );
8528 assert!(
8529 !content.contains(&legacy_ciphertext),
8530 "legacy ciphertext leaked to {name}"
8531 );
8532 }
8533 }
8534 }
8535
8536 #[test]
8539 fn env_vars_as_map_includes_only_non_empty_values() {
8540 let mut config = Config::default();
8541 config.env_vars.extend([
8542 EnvVarEntry {
8543 name: "A".to_string(),
8544 value: "val_a".to_string(),
8545 secret: false,
8546 value_encrypted: None,
8547 credential_ref: None,
8548 configured: true,
8549 description: None,
8550 },
8551 EnvVarEntry {
8552 name: "B".to_string(),
8553 value: "".to_string(), secret: true,
8555 value_encrypted: None,
8556 credential_ref: None,
8557 configured: false,
8558 description: None,
8559 },
8560 EnvVarEntry {
8561 name: "C".to_string(),
8562 value: " ".to_string(), secret: false,
8564 value_encrypted: None,
8565 credential_ref: None,
8566 configured: true,
8567 description: None,
8568 },
8569 EnvVarEntry {
8570 name: "D".to_string(),
8571 value: "val_d".to_string(),
8572 secret: true,
8573 value_encrypted: Some("enc".to_string()),
8574 credential_ref: None,
8575 configured: true,
8576 description: Some("desc".to_string()),
8577 },
8578 ]);
8579
8580 let map = config.env_vars_as_map();
8581 assert_eq!(map.len(), 2);
8582 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
8583 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
8584 assert!(!map.contains_key("B"));
8585 assert!(!map.contains_key("C"));
8586 }
8587
8588 #[test]
8589 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
8590 let mut config = Config::default();
8591 config.env_vars.extend([
8592 EnvVarEntry {
8593 name: "PLAIN".to_string(),
8594 value: "visible".to_string(),
8595 secret: false,
8596 value_encrypted: None,
8597 credential_ref: None,
8598 configured: true,
8599 description: None,
8600 },
8601 EnvVarEntry {
8602 name: "SECRET".to_string(),
8603 value: "hidden_value".to_string(),
8604 secret: true,
8605 value_encrypted: Some("enc_data".to_string()),
8606 credential_ref: None,
8607 configured: true,
8608 description: None,
8609 },
8610 ]);
8611
8612 config.sanitize_env_vars_for_disk();
8613
8614 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
8617
8618 #[test]
8619 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
8620 let mut config = Config::default();
8621 config.env_vars.extend([
8622 EnvVarEntry {
8623 name: "OPEN".to_string(),
8624 value: "val".to_string(),
8625 secret: false,
8626 value_encrypted: None,
8627 credential_ref: None,
8628 configured: true,
8629 description: None,
8630 },
8631 EnvVarEntry {
8632 name: "HIDDEN".to_string(),
8633 value: "real_secret".to_string(),
8634 secret: true,
8635 value_encrypted: Some("enc".to_string()),
8636 credential_ref: None,
8637 configured: true,
8638 description: None,
8639 },
8640 ]);
8641
8642 config.sanitize_env_vars_for_disk();
8643
8644 assert_eq!(config.env_vars[0].value, "val");
8646 assert_eq!(config.env_vars[1].value, "");
8648 assert!(config.env_vars[1].value_encrypted.is_none());
8649 }
8650
8651 #[test]
8652 fn legacy_env_ciphertext_is_read_but_never_serialized() {
8653 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
8654 let mut config: Config = serde_json::from_value(serde_json::json!({
8655 "env_vars": [{
8656 "name": "TOKEN",
8657 "secret": true,
8658 "value_encrypted": ciphertext
8659 }]
8660 }))
8661 .unwrap();
8662 config.hydrate_env_vars_from_encrypted();
8663 assert_eq!(config.env_vars[0].value, "my-secret-token");
8664 let serialized = serde_json::to_value(&config).unwrap();
8665 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
8666 }
8667
8668 #[test]
8669 fn configured_env_ref_missing_from_store_fails_closed() {
8670 let dir = tempfile::tempdir().unwrap();
8671 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
8672 let mut config = Config::default();
8673 config.env_vars.push(EnvVarEntry {
8674 name: "TOKEN".to_string(),
8675 value: String::new(),
8676 secret: true,
8677 value_encrypted: None,
8678 credential_ref: Some(reference),
8679 configured: true,
8680 description: None,
8681 });
8682 let error = config
8683 .hydrate_env_var_credentials_from_store(dir.path())
8684 .unwrap_err();
8685 assert!(error
8686 .to_string()
8687 .contains("referenced env credential is unavailable"));
8688 assert!(config.env_vars[0].configured);
8689 assert!(config.env_vars[0].value.is_empty());
8690 }
8691
8692 #[test]
8693 fn configured_notification_ref_missing_from_store_fails_closed() {
8694 let dir = tempfile::tempdir().unwrap();
8695 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
8696 let mut config = Config::default();
8697 config.notifications.ntfy.credential_ref = Some(reference);
8698 config.notifications.ntfy.configured = true;
8699
8700 let error = config
8701 .hydrate_notification_credentials_from_store(dir.path())
8702 .unwrap_err();
8703
8704 assert!(error
8705 .to_string()
8706 .contains("referenced ntfy credential is unavailable"));
8707 assert!(config.notifications.ntfy.configured);
8708 assert!(config.notifications.ntfy.token.is_none());
8709 }
8710
8711 #[test]
8712 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
8713 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
8714 let dir = tempfile::tempdir().unwrap();
8715 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
8716 crate::CredentialStore::open(dir.path())
8717 .replace(
8718 reference.clone(),
8719 "exclusive-notification-secret",
8720 crate::CredentialSource::User,
8721 0,
8722 )
8723 .unwrap();
8724
8725 let mut config = Config::default();
8726 config.notifications.ntfy.credential_ref = Some(reference.clone());
8727 config.notifications.ntfy.configured = true;
8728 config.proxy_auth_credential_ref = Some(reference.clone());
8729 let error = config
8730 .hydrate_notification_credentials_from_store(dir.path())
8731 .unwrap_err();
8732 let rendered = error.to_string();
8733 assert!(rendered
8734 .contains("notification credential reference is shared by another config consumer"));
8735 assert!(!rendered.contains(reference.as_str()));
8736 assert!(config.notifications.ntfy.token.is_none());
8737
8738 config.proxy_auth_credential_ref = None;
8739 config
8740 .hydrate_notification_credentials_from_store(dir.path())
8741 .unwrap();
8742 assert_eq!(
8743 config.notifications.ntfy.token.as_deref(),
8744 Some("exclusive-notification-secret")
8745 );
8746 }
8747
8748 #[test]
8749 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
8750 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
8751 let dir = tempfile::tempdir().unwrap();
8752 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
8753 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
8754 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
8755 "notifications": {
8756 "ntfy": { "enabled": true, "token_encrypted": ntfy },
8757 "bark": { "enabled": true, "device_key_encrypted": bark }
8758 }
8759 }))
8760 .unwrap();
8761 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
8762 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
8763
8764 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
8765
8766 assert!(loaded.notifications.ntfy.token.is_none());
8767 assert!(loaded.notifications.bark.device_key.is_none());
8768 assert_eq!(
8769 std::fs::read(dir.path().join("config.json")).unwrap(),
8770 bytes
8771 );
8772 }
8773
8774 #[test]
8775 fn publish_and_current_env_vars_round_trip() {
8776 let _lock = crate::test_support::env_cache_lock_acquire();
8786 let mut config = Config::default();
8787 config.env_vars.extend([EnvVarEntry {
8788 name: "TEST_PUBLISH".to_string(),
8789 value: "pub_value".to_string(),
8790 secret: false,
8791 value_encrypted: None,
8792 credential_ref: None,
8793 configured: true,
8794 description: None,
8795 }]);
8796
8797 config.publish_env_vars();
8798 assert_eq!(
8799 Config::current_env_vars()
8800 .get("TEST_PUBLISH")
8801 .map(String::as_str),
8802 Some("pub_value")
8803 );
8804 }
8805
8806 #[test]
8807 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
8808 let mut config = Config::default();
8809 config.subagents.broker = Some(BrokerClientConfig {
8810 endpoint: "ws://127.0.0.1:9600".to_string(),
8811 token: "super-secret-token".to_string(),
8812 token_encrypted: None,
8813 credential_ref: None,
8814 configured: false,
8815 });
8816
8817 config.refresh_broker_token_encrypted().unwrap();
8819 config.sanitize_broker_token_for_disk();
8820 let broker = config.subagents.broker.as_ref().unwrap();
8821 assert!(broker.token.is_empty(), "plaintext cleared for disk");
8822 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
8823 assert_ne!(
8824 broker.token_encrypted.as_deref(),
8825 Some("super-secret-token")
8826 );
8827
8828 config.hydrate_broker_token_from_encrypted();
8830 assert_eq!(
8831 config.subagents.broker.as_ref().unwrap().token,
8832 "super-secret-token"
8833 );
8834 }
8835
8836 #[test]
8837 fn broker_token_empty_refresh_preserves_ciphertext() {
8838 let mut config = Config::default();
8840 config.subagents.broker = Some(BrokerClientConfig {
8841 endpoint: "ws://h:9600".to_string(),
8842 token: String::new(),
8843 token_encrypted: Some("existing-cipher".to_string()),
8844 credential_ref: None,
8845 configured: false,
8846 });
8847 config.refresh_broker_token_encrypted().unwrap();
8848 assert_eq!(
8849 config
8850 .subagents
8851 .broker
8852 .as_ref()
8853 .unwrap()
8854 .token_encrypted
8855 .as_deref(),
8856 Some("existing-cipher"),
8857 );
8858 }
8859
8860 #[test]
8861 fn notifications_config_defaults_when_key_missing() {
8862 let config: Config = serde_json::from_str("{}").expect("empty object parses");
8865 assert_eq!(config.notifications, NotificationsConfig::default());
8866 assert_eq!(config.notifications.desktop.enabled, None);
8867 assert!(!config.notifications.ntfy.enabled);
8868 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
8869 assert_eq!(config.notifications.ntfy.token, None);
8870 assert!(!config.notifications.bark.enabled);
8871 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
8872 assert_eq!(config.notifications.bark.device_key, None);
8873 }
8874
8875 #[test]
8876 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
8877 let mut config = Config::default();
8878 config.notifications.ntfy = NtfyChannelConfig {
8879 enabled: true,
8880 base_url: "https://ntfy.sh".to_string(),
8881 topic: "bamboo-alerts".to_string(),
8882 token: Some("tk_super_secret".to_string()),
8883 token_encrypted: None,
8884 credential_ref: None,
8885 configured: false,
8886 };
8887
8888 config.refresh_notifications_encrypted().unwrap();
8891 assert!(config.notifications.ntfy.token_encrypted.is_some());
8892 assert_ne!(
8893 config.notifications.ntfy.token_encrypted.as_deref(),
8894 Some("tk_super_secret")
8895 );
8896
8897 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
8899 assert!(
8900 !json.contains("tk_super_secret"),
8901 "plaintext token must never be serialized"
8902 );
8903 assert!(!json.contains("token_encrypted"));
8904
8905 config.notifications.ntfy.token = None;
8907 config.hydrate_notifications_from_encrypted();
8908 assert_eq!(
8909 config.notifications.ntfy.token.as_deref(),
8910 Some("tk_super_secret")
8911 );
8912 }
8913
8914 #[test]
8915 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
8916 let mut config = Config::default();
8917 config.notifications.bark = BarkChannelConfig {
8918 enabled: true,
8919 base_url: "https://api.day.app".to_string(),
8920 device_key: Some("dk_super_secret".to_string()),
8921 device_key_encrypted: None,
8922 credential_ref: None,
8923 configured: false,
8924 };
8925
8926 config.refresh_notifications_encrypted().unwrap();
8927 assert!(config.notifications.bark.device_key_encrypted.is_some());
8928 assert_ne!(
8929 config.notifications.bark.device_key_encrypted.as_deref(),
8930 Some("dk_super_secret")
8931 );
8932
8933 let json = serde_json::to_string(&config.notifications.bark).unwrap();
8934 assert!(
8935 !json.contains("dk_super_secret"),
8936 "plaintext device key must never be serialized"
8937 );
8938 assert!(!json.contains("device_key_encrypted"));
8939
8940 config.notifications.bark.device_key = None;
8941 config.hydrate_notifications_from_encrypted();
8942 assert_eq!(
8943 config.notifications.bark.device_key.as_deref(),
8944 Some("dk_super_secret")
8945 );
8946 }
8947
8948 #[test]
8949 fn notification_secrets_empty_refresh_preserves_ciphertext() {
8950 let mut config = Config::default();
8953 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
8954 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
8955
8956 config.refresh_notifications_encrypted().unwrap();
8957
8958 assert_eq!(
8959 config.notifications.ntfy.token_encrypted.as_deref(),
8960 Some("existing-ntfy-cipher")
8961 );
8962 assert_eq!(
8963 config.notifications.bark.device_key_encrypted.as_deref(),
8964 Some("existing-bark-cipher")
8965 );
8966 }
8967
8968 #[test]
8969 fn hydrate_skips_non_secret_entries() {
8970 let mut config = Config::default();
8971 config.env_vars.extend([EnvVarEntry {
8972 name: "PLAIN".to_string(),
8973 value: "original".to_string(),
8974 secret: false,
8975 value_encrypted: Some("should-be-ignored".to_string()),
8976 credential_ref: None,
8977 configured: true,
8978 description: None,
8979 }]);
8980
8981 config.hydrate_env_vars_from_encrypted();
8982 assert_eq!(config.env_vars[0].value, "original");
8984 }
8985
8986 #[test]
8987 fn default_config_has_empty_env_vars() {
8988 assert!(Config::default().env_vars.is_empty());
8993 }
8994
8995 #[test]
8996 fn serde_round_trip_with_env_vars() {
8997 let mut config = Config::default();
8998 config.env_vars.extend([
8999 EnvVarEntry {
9000 name: "KEY1".to_string(),
9001 value: "val1".to_string(),
9002 secret: false,
9003 value_encrypted: None,
9004 credential_ref: None,
9005 configured: true,
9006 description: Some("First key".to_string()),
9007 },
9008 EnvVarEntry {
9009 name: "KEY2".to_string(),
9010 value: "".to_string(), secret: true,
9012 value_encrypted: Some("enc123".to_string()),
9013 credential_ref: None,
9014 configured: true,
9015 description: None,
9016 },
9017 ]);
9018
9019 let json = serde_json::to_string(&config).unwrap();
9020 let restored: Config = serde_json::from_str(&json).unwrap();
9021
9022 assert_eq!(restored.env_vars.len(), 2);
9023 assert_eq!(restored.env_vars[0].name, "KEY1");
9024 assert_eq!(restored.env_vars[0].value, "val1");
9025 assert!(!restored.env_vars[0].secret);
9026 assert_eq!(restored.env_vars[1].name, "KEY2");
9027 assert!(restored.env_vars[1].secret);
9028 assert!(restored.env_vars[1].value_encrypted.is_none());
9029 }
9030
9031 #[test]
9034 #[allow(clippy::field_reassign_with_default)]
9036 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
9037 let mut config = Config::default();
9038 config.provider = "openai".to_string();
9039 config.providers.openai = Some(OpenAIConfig {
9040 api_key: "test".to_string(),
9041 api_key_encrypted: None,
9042 credential_ref: None,
9043 base_url: None,
9044 model: Some("legacy-gpt-4o".to_string()),
9045 fast_model: None,
9046 vision_model: None,
9047 reasoning_effort: None,
9048 responses_only_models: vec![],
9049 request_overrides: None,
9050 extra: Default::default(),
9051 api_key_from_env: false,
9052 });
9053 config.features.provider_model_ref = true;
9054 config.defaults = Some(DefaultsConfig {
9055 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9056 fast: None,
9057 task_summary: None,
9058 vision: None,
9059 memory_background: None,
9060 planning: None,
9061 search: None,
9062 code_review: None,
9063 sub_agent: None,
9064 subagent_models: Default::default(),
9065 });
9066
9067 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
9068 }
9069
9070 #[test]
9071 #[allow(clippy::field_reassign_with_default)]
9073 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
9074 let mut config = Config::default();
9075 config.provider = "openai".to_string();
9076 config.providers.openai = Some(OpenAIConfig {
9077 api_key: "test".to_string(),
9078 api_key_encrypted: None,
9079 credential_ref: None,
9080 base_url: None,
9081 model: Some("legacy-gpt-4o".to_string()),
9082 fast_model: None,
9083 vision_model: None,
9084 reasoning_effort: None,
9085 responses_only_models: vec![],
9086 request_overrides: None,
9087 extra: Default::default(),
9088 api_key_from_env: false,
9089 });
9090 config.features.provider_model_ref = false;
9091 config.defaults = Some(DefaultsConfig {
9092 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9093 fast: None,
9094 task_summary: None,
9095 vision: None,
9096 memory_background: None,
9097 planning: None,
9098 search: None,
9099 code_review: None,
9100 sub_agent: None,
9101 subagent_models: Default::default(),
9102 });
9103
9104 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
9105 }
9106
9107 #[test]
9108 #[allow(clippy::field_reassign_with_default)]
9110 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
9111 let mut config = Config::default();
9112 config.provider = "openai".to_string();
9113 config.providers.openai = Some(OpenAIConfig {
9114 api_key: "test".to_string(),
9115 api_key_encrypted: None,
9116 credential_ref: None,
9117 base_url: None,
9118 model: Some("gpt-4o".to_string()),
9119 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9120 vision_model: None,
9121 reasoning_effort: None,
9122 responses_only_models: vec![],
9123 request_overrides: None,
9124 extra: Default::default(),
9125 api_key_from_env: false,
9126 });
9127 config.features.provider_model_ref = true;
9128 config.defaults = Some(DefaultsConfig {
9129 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9130 fast: Some(bamboo_domain::ProviderModelRef::new(
9131 "anthropic",
9132 "claude-3-5-haiku",
9133 )),
9134 task_summary: None,
9135 vision: None,
9136 memory_background: None,
9137 planning: None,
9138 search: None,
9139 code_review: None,
9140 sub_agent: None,
9141 subagent_models: Default::default(),
9142 });
9143
9144 assert_eq!(
9145 config.get_fast_model(),
9146 Some("claude-3-5-haiku".to_string())
9147 );
9148 }
9149
9150 #[test]
9151 #[allow(clippy::field_reassign_with_default)]
9153 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
9154 let mut config = Config::default();
9155 config.provider = "openai".to_string();
9156 config.providers.openai = Some(OpenAIConfig {
9157 api_key: "test".to_string(),
9158 api_key_encrypted: None,
9159 credential_ref: None,
9160 base_url: None,
9161 model: Some("gpt-4o".to_string()),
9162 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9163 vision_model: None,
9164 reasoning_effort: None,
9165 responses_only_models: vec![],
9166 request_overrides: None,
9167 extra: Default::default(),
9168 api_key_from_env: false,
9169 });
9170 config.features.provider_model_ref = false;
9171 config.defaults = Some(DefaultsConfig {
9172 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9173 fast: Some(bamboo_domain::ProviderModelRef::new(
9174 "anthropic",
9175 "claude-3-5-haiku",
9176 )),
9177 task_summary: None,
9178 vision: None,
9179 memory_background: None,
9180 planning: None,
9181 search: None,
9182 code_review: None,
9183 sub_agent: None,
9184 subagent_models: Default::default(),
9185 });
9186
9187 assert_eq!(
9188 config.get_fast_model(),
9189 Some("legacy-gpt-4o-mini".to_string())
9190 );
9191 }
9192
9193 #[test]
9194 #[allow(clippy::field_reassign_with_default)]
9196 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
9197 let mut config = Config::default();
9198 config.provider = "openai".to_string();
9199 config.features.provider_model_ref = true;
9200 config.defaults = Some(DefaultsConfig {
9201 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9202 fast: None,
9203 task_summary: None,
9204 vision: None,
9205 memory_background: None,
9206 planning: None,
9207 search: None,
9208 code_review: None,
9209 sub_agent: None,
9210 subagent_models: Default::default(),
9211 });
9212
9213 assert_eq!(
9214 config.get_fast_model(),
9215 Some("claude-3-7-sonnet".to_string())
9216 );
9217 }
9218
9219 #[test]
9220 #[allow(clippy::field_reassign_with_default)]
9222 fn get_memory_background_model_prefers_defaults_memory_background() {
9223 let mut config = Config::default();
9224 config.provider = "openai".to_string();
9225 config.providers.openai = Some(OpenAIConfig {
9226 api_key: "test".to_string(),
9227 api_key_encrypted: None,
9228 credential_ref: None,
9229 base_url: None,
9230 model: Some("gpt-4o".to_string()),
9231 fast_model: Some("gpt-4o-mini".to_string()),
9232 vision_model: None,
9233 reasoning_effort: None,
9234 responses_only_models: vec![],
9235 request_overrides: None,
9236 extra: Default::default(),
9237 api_key_from_env: false,
9238 });
9239 config.features.provider_model_ref = true;
9240 config.defaults = Some(DefaultsConfig {
9241 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9242 fast: Some(bamboo_domain::ProviderModelRef::new(
9243 "openai",
9244 "gpt-4o-mini",
9245 )),
9246 task_summary: None,
9247 vision: None,
9248 memory_background: Some(bamboo_domain::ProviderModelRef::new(
9249 "anthropic",
9250 "claude-3-5-haiku",
9251 )),
9252 planning: None,
9253 search: None,
9254 code_review: None,
9255 sub_agent: None,
9256 subagent_models: Default::default(),
9257 });
9258
9259 assert_eq!(
9260 config.get_memory_background_model(),
9261 Some("claude-3-5-haiku".to_string())
9262 );
9263 }
9264
9265 #[test]
9266 #[allow(clippy::field_reassign_with_default)]
9268 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
9269 let mut config = Config::default();
9270 config.provider = "openai".to_string();
9271 config.features.provider_model_ref = true;
9272 config.defaults = Some(DefaultsConfig {
9273 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9274 fast: Some(bamboo_domain::ProviderModelRef::new(
9275 "anthropic",
9276 "claude-3-5-haiku",
9277 )),
9278 task_summary: None,
9279 vision: None,
9280 memory_background: None,
9281 planning: None,
9282 search: None,
9283 code_review: None,
9284 sub_agent: None,
9285 subagent_models: Default::default(),
9286 });
9287
9288 assert_eq!(
9289 config.get_memory_background_model(),
9290 Some("claude-3-5-haiku".to_string())
9291 );
9292 }
9293
9294 #[test]
9295 #[allow(clippy::field_reassign_with_default)]
9297 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
9298 let mut config = Config::default();
9299 config.provider = "openai".to_string();
9300 config.providers.openai = Some(OpenAIConfig {
9301 api_key: "test".to_string(),
9302 api_key_encrypted: None,
9303 credential_ref: None,
9304 base_url: None,
9305 model: Some("gpt-4o".to_string()),
9306 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9307 vision_model: None,
9308 reasoning_effort: None,
9309 responses_only_models: vec![],
9310 request_overrides: None,
9311 extra: Default::default(),
9312 api_key_from_env: false,
9313 });
9314 config.features.provider_model_ref = false;
9315 config.defaults = Some(DefaultsConfig {
9316 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9317 fast: Some(bamboo_domain::ProviderModelRef::new(
9318 "anthropic",
9319 "claude-3-5-haiku",
9320 )),
9321 task_summary: None,
9322 vision: None,
9323 memory_background: Some(bamboo_domain::ProviderModelRef::new(
9324 "anthropic",
9325 "claude-3-5-haiku",
9326 )),
9327 planning: None,
9328 search: None,
9329 code_review: None,
9330 sub_agent: None,
9331 subagent_models: Default::default(),
9332 });
9333
9334 assert_eq!(
9335 config.get_memory_background_model(),
9336 Some("legacy-gpt-4o-mini".to_string())
9337 );
9338 }
9339
9340 #[test]
9347 fn is_host_trusted_requires_https_scheme() {
9348 let hosts = vec!["github.com/bigduu/".to_string()];
9349 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
9350 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9351 }
9352
9353 #[test]
9354 fn is_host_trusted_is_case_insensitive_on_both_sides() {
9355 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
9357 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9358 let hosts = vec!["github.com/bigduu/".to_string()];
9360 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
9361 }
9362
9363 #[test]
9364 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
9365 let hosts = vec!["trusted.example.com".to_string()];
9366 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
9367 assert!(!is_host_trusted(
9371 "https://trusted.example.com.evil.com/x",
9372 &hosts
9373 ));
9374 assert!(!is_host_trusted(
9375 "https://trusted.example.comevil.com/x",
9376 &hosts
9377 ));
9378 }
9379
9380 #[test]
9381 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
9382 let hosts = vec!["github.com/bigduu/".to_string()];
9384 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9385 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
9386 }
9387
9388 #[test]
9389 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
9390 let hosts = vec!["example.com".to_string()];
9391 assert!(is_host_trusted("https://example.com/", &hosts));
9392 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
9393 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
9396 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
9397 }
9398
9399 #[test]
9400 fn is_host_trusted_uses_the_real_host_not_userinfo() {
9401 let hosts = vec!["github.com/bigduu/".to_string()];
9402 assert!(is_host_trusted(
9404 "https://someuser@github.com/bigduu/x",
9405 &hosts
9406 ));
9407 assert!(!is_host_trusted(
9410 "https://github.com@evil.com/bigduu/",
9411 &hosts
9412 ));
9413 }
9414
9415 #[test]
9416 fn is_host_trusted_ignores_an_explicit_port() {
9417 let hosts = vec!["github.com/bigduu/".to_string()];
9418 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
9419 }
9420
9421 #[test]
9422 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
9423 let hosts = vec!["github.com/bigduu/".to_string()];
9424 assert!(!is_host_trusted("not a url at all", &hosts));
9425 assert!(!is_host_trusted("", &hosts));
9426 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
9428
9429 #[test]
9430 fn is_host_trusted_normalizes_dot_segments_before_matching() {
9431 let hosts = vec!["github.com/bigduu/".to_string()];
9432 assert!(!is_host_trusted(
9435 "https://github.com/bigduu/../evil/x",
9436 &hosts
9437 ));
9438 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
9440 }
9441
9442 #[test]
9443 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
9444 let mut config = Config::default();
9445 config.plugin_trust.trusted_hosts = vec![
9446 " GitHub.com/BigDuu/ ".to_string(),
9447 "".to_string(),
9448 " ".to_string(),
9449 "Example.COM".to_string(),
9450 ];
9451 config.normalize_plugin_trust_settings();
9452 assert_eq!(
9453 config.plugin_trust.trusted_hosts,
9454 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
9455 );
9456 }
9457
9458 #[test]
9464 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
9465 let config = Config::default();
9467 assert_eq!(
9468 config.plugin_trust.enforcement,
9469 PluginTrustEnforcement::Strict
9470 );
9471 assert!(!config.plugin_trust.enforcement_is_off());
9472
9473 let json = serde_json::json!({
9479 "trusted_hosts": ["example.com"],
9480 "trusted_keys": [],
9481 });
9482 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
9483 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
9484 }
9485
9486 #[test]
9487 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
9488 for raw in ["off", "OFF", "Off", " off "] {
9489 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
9490 "enforcement": raw,
9491 }))
9492 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
9493 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
9494 assert!(trust.enforcement_is_off());
9495 }
9496 for raw in ["strict", "STRICT", " Strict "] {
9497 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
9498 "enforcement": raw,
9499 }))
9500 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
9501 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
9502 }
9503
9504 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
9505 "enforcement": "nonsense",
9506 }))
9507 .expect_err("an unrecognized string must be rejected, not silently default");
9508 assert!(err.to_string().contains("nonsense"));
9509 }
9510
9511 #[test]
9512 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
9513 let trust: PluginTrustConfig =
9516 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
9517 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
9518
9519 let trust: PluginTrustConfig =
9520 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
9521 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
9522 }
9523
9524 #[test]
9525 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
9526 let trust = PluginTrustConfig {
9531 enforcement: PluginTrustEnforcement::Off,
9532 ..PluginTrustConfig::default()
9533 };
9534 let json = serde_json::to_value(&trust).unwrap();
9535 assert_eq!(json["enforcement"], "off");
9536
9537 let trust = PluginTrustConfig {
9538 enforcement: PluginTrustEnforcement::Strict,
9539 ..PluginTrustConfig::default()
9540 };
9541 let json = serde_json::to_value(&trust).unwrap();
9542 assert_eq!(json["enforcement"], "strict");
9543 }
9544
9545 #[test]
9546 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
9547 let mut config = Config::default();
9550 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
9551 config.normalize_plugin_trust_settings();
9552 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
9553 }
9554
9555 #[test]
9556 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
9557 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
9562 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
9563 )));
9564 assert_eq!(
9565 config.plugin_trust.enforcement,
9566 PluginTrustEnforcement::Strict
9567 );
9568
9569 let outcome = crate::dot_path::apply_dot_path_set(
9570 &config,
9571 "plugin_trust.enforcement",
9572 crate::dot_path::parse_cli_value("off"),
9573 )
9574 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
9575 assert_eq!(
9576 outcome.config.plugin_trust.enforcement,
9577 PluginTrustEnforcement::Off
9578 );
9579
9580 let outcome = crate::dot_path::apply_dot_path_set(
9582 &outcome.config,
9583 "plugin_trust.enforcement",
9584 crate::dot_path::parse_cli_value("strict"),
9585 )
9586 .expect("setting it back to strict should also round-trip");
9587 assert_eq!(
9588 outcome.config.plugin_trust.enforcement,
9589 PluginTrustEnforcement::Strict
9590 );
9591 }
9592}