1use anyhow::{Context, Result};
60use bamboo_domain::poison::PoisonRecover;
61use serde::{Deserialize, Serialize};
62use serde_json::Value;
63use std::collections::{BTreeMap, BTreeSet, HashMap};
64use std::path::{Path, PathBuf};
65use std::sync::{OnceLock, RwLock};
66
67use crate::keyword_masking::KeywordMaskingConfig;
68use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
69use bamboo_domain::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
2002fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2009 let instance_native = values
2010 .default_provider_instance
2011 .as_ref()
2012 .is_some_and(|id| values.provider_instances.contains_key(id));
2013 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2014 if instance_native {
2015 if let Some(object) = value.as_object_mut() {
2016 object.remove("provider");
2017 object.remove("providers");
2018 }
2019 }
2020 Ok(value)
2021}
2022
2023define_counted_struct! {
2024 #[derive(Debug, Clone)]
2027 pub struct Config {
2028 values: ConfigValues,
2029 pub(crate) memory: crate::MemoryConfigModule,
2030 pub(crate) subagents: crate::SubagentsConfigModule,
2031 pub(crate) providers: crate::ProviderConfigsModule,
2032 recovery_status: Option<ConfigRecoveryStatus>,
2033 }
2034 count pub CONFIG_FIELD_COUNT;
2035}
2036
2037pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2039const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2040const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2041
2042impl std::ops::Deref for Config {
2043 type Target = ConfigValues;
2044
2045 fn deref(&self) -> &Self::Target {
2046 &self.values
2047 }
2048}
2049
2050impl std::ops::DerefMut for Config {
2051 fn deref_mut(&mut self) -> &mut Self::Target {
2052 &mut self.values
2053 }
2054}
2055
2056impl Serialize for Config {
2057 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2058 where
2059 S: serde::Serializer,
2060 {
2061 self.to_compatibility_value()
2062 .map_err(serde::ser::Error::custom)?
2063 .serialize(serializer)
2064 }
2065}
2066
2067impl<'de> Deserialize<'de> for Config {
2068 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2069 where
2070 D: serde::Deserializer<'de>,
2071 {
2072 use serde::de::Error;
2073
2074 let mut value = Value::deserialize(deserializer)?;
2075 let object = value
2076 .as_object_mut()
2077 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2078 let memory = object
2079 .remove("memory")
2080 .map(serde_json::from_value)
2081 .transpose()
2082 .map_err(D::Error::custom)?
2083 .unwrap_or_default();
2084 let subagents = object
2085 .remove("subagents")
2086 .map(serde_json::from_value)
2087 .transpose()
2088 .map_err(D::Error::custom)?
2089 .unwrap_or_default();
2090 let providers = object
2091 .remove("providers")
2092 .map(serde_json::from_value)
2093 .transpose()
2094 .map_err(D::Error::custom)?
2095 .unwrap_or_default();
2096 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2097
2098 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2099 }
2100}
2101
2102#[derive(Debug, Clone, PartialEq, Serialize)]
2104#[serde(tag = "kind", rename_all = "snake_case")]
2105pub enum ConfigRecoverySource {
2106 Salvaged { fields: Vec<String> },
2111 Backup { generation: usize },
2114 Defaults,
2116}
2117
2118#[derive(Debug, Clone, PartialEq, Serialize)]
2130pub struct ConfigRecoveryStatus {
2131 pub source: ConfigRecoverySource,
2133 pub quarantine_path: Option<PathBuf>,
2139 pub confirmed: bool,
2142}
2143
2144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2148pub struct ProviderConfigs {
2149 #[serde(skip_serializing_if = "Option::is_none")]
2151 pub openai: Option<OpenAIConfig>,
2152 #[serde(skip_serializing_if = "Option::is_none")]
2154 pub anthropic: Option<AnthropicConfig>,
2155 #[serde(skip_serializing_if = "Option::is_none")]
2157 pub gemini: Option<GeminiConfig>,
2158 #[serde(skip_serializing_if = "Option::is_none")]
2160 pub copilot: Option<CopilotConfig>,
2161 #[serde(skip_serializing_if = "Option::is_none")]
2163 pub bodhi: Option<BodhiConfig>,
2164
2165 #[serde(default, flatten)]
2167 pub extra: BTreeMap<String, Value>,
2168}
2169
2170impl ProviderConfigs {
2171 pub fn clear_legacy_builtin_aliases(&mut self) {
2174 self.openai = None;
2175 self.anthropic = None;
2176 self.gemini = None;
2177 self.copilot = None;
2178 self.bodhi = None;
2179 self.extra.remove("provider");
2180 }
2181}
2182
2183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2185pub struct FeatureFlags {
2186 #[serde(default)]
2188 pub provider_model_ref: bool,
2189 #[serde(default)]
2191 pub dynamic_model_routing: bool,
2192}
2193
2194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2198pub struct DefaultsConfig {
2199 pub chat: bamboo_domain::ProviderModelRef,
2200 #[serde(default, skip_serializing_if = "Option::is_none")]
2201 pub fast: Option<bamboo_domain::ProviderModelRef>,
2202 #[serde(default, skip_serializing_if = "Option::is_none")]
2203 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2204 #[serde(default, skip_serializing_if = "Option::is_none")]
2205 pub vision: Option<bamboo_domain::ProviderModelRef>,
2206 #[serde(default, skip_serializing_if = "Option::is_none")]
2207 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2208 #[serde(default, skip_serializing_if = "Option::is_none")]
2211 pub planning: Option<bamboo_domain::ProviderModelRef>,
2212 #[serde(default, skip_serializing_if = "Option::is_none")]
2215 pub search: Option<bamboo_domain::ProviderModelRef>,
2216 #[serde(default, skip_serializing_if = "Option::is_none")]
2219 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2220 #[serde(
2223 default,
2224 skip_serializing_if = "Option::is_none",
2225 alias = "sub_session"
2226 )]
2227 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2228 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2232 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2233}
2234
2235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2237pub struct HooksConfig {
2238 #[serde(default)]
2240 pub image_fallback: ImageFallbackHookConfig,
2241}
2242
2243pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2245pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2247pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2251
2252pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2254 "SessionStart",
2255 "UserPromptSubmit",
2256 "PreToolUse",
2257 "PostToolUse",
2258 "Stop",
2259 "SessionEnd",
2260 "PreCompact",
2261 "Notification",
2262];
2263
2264fn default_lifecycle_hook_timeout_ms() -> u64 {
2265 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2266}
2267
2268fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2269 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2270}
2271
2272fn lifecycle_hook_enabled_default() -> bool {
2273 true
2274}
2275
2276fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2277 *value
2278}
2279
2280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2285pub struct LifecycleHooksConfig {
2286 #[serde(default)]
2287 pub enabled: bool,
2288 #[serde(
2289 default,
2290 rename = "SessionStart",
2291 skip_serializing_if = "Vec::is_empty"
2292 )]
2293 pub session_start: Vec<LifecycleHookGroup>,
2294 #[serde(
2295 default,
2296 rename = "UserPromptSubmit",
2297 skip_serializing_if = "Vec::is_empty"
2298 )]
2299 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2300 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2301 pub pre_tool_use: Vec<LifecycleHookGroup>,
2302 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2303 pub post_tool_use: Vec<LifecycleHookGroup>,
2304 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2305 pub stop: Vec<LifecycleHookGroup>,
2306 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2307 pub session_end: Vec<LifecycleHookGroup>,
2308 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2309 pub pre_compact: Vec<LifecycleHookGroup>,
2310 #[serde(
2311 default,
2312 rename = "Notification",
2313 skip_serializing_if = "Vec::is_empty"
2314 )]
2315 pub notification: Vec<LifecycleHookGroup>,
2316}
2317
2318impl LifecycleHooksConfig {
2319 pub fn is_empty(&self) -> bool {
2320 !self.enabled
2321 && self.session_start.is_empty()
2322 && self.user_prompt_submit.is_empty()
2323 && self.pre_tool_use.is_empty()
2324 && self.post_tool_use.is_empty()
2325 && self.stop.is_empty()
2326 && self.session_end.is_empty()
2327 && self.pre_compact.is_empty()
2328 && self.notification.is_empty()
2329 }
2330}
2331
2332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2334pub struct LifecycleHookGroup {
2335 #[serde(
2338 default = "lifecycle_hook_enabled_default",
2339 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2340 )]
2341 pub enabled: bool,
2342 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 pub matcher: Option<String>,
2344 #[serde(default)]
2345 pub hooks: Vec<LifecycleHookHandler>,
2346}
2347
2348impl Default for LifecycleHookGroup {
2349 fn default() -> Self {
2350 Self {
2351 enabled: true,
2352 matcher: None,
2353 hooks: Vec::new(),
2354 }
2355 }
2356}
2357
2358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2363#[serde(tag = "type", rename_all = "lowercase")]
2364pub enum LifecycleHookHandler {
2365 Command {
2366 command: String,
2367 #[serde(
2368 default = "default_lifecycle_hook_timeout_ms",
2369 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2370 )]
2371 timeout_ms: u64,
2372 },
2373 Script {
2374 path: String,
2375 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2376 runner: LifecycleScriptRunner,
2377 #[serde(
2378 default = "default_lifecycle_hook_timeout_ms",
2379 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2380 )]
2381 timeout_ms: u64,
2382 },
2383}
2384
2385#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2391#[serde(rename_all = "lowercase")]
2392pub enum LifecycleScriptRunner {
2393 #[default]
2394 Auto,
2395 Node,
2396 Bun,
2397 Python,
2398 Bash,
2399 PowerShell,
2400 Cmd,
2401}
2402
2403impl LifecycleScriptRunner {
2404 pub fn is_auto(&self) -> bool {
2405 matches!(self, Self::Auto)
2406 }
2407
2408 pub fn as_str(self) -> &'static str {
2409 match self {
2410 Self::Auto => "auto",
2411 Self::Node => "node",
2412 Self::Bun => "bun",
2413 Self::Python => "python",
2414 Self::Bash => "bash",
2415 Self::PowerShell => "powershell",
2416 Self::Cmd => "cmd",
2417 }
2418 }
2419
2420 pub fn supports_path(self, path: &str) -> bool {
2422 let extension = lifecycle_script_extension(path);
2423 match self {
2424 Self::Auto => extension.is_some(),
2425 Self::Node | Self::Bun => {
2426 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2427 }
2428 Self::Python => matches!(extension.as_deref(), Some("py")),
2429 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2430 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2431 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2432 }
2433 }
2434}
2435
2436pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2439 let extension = std::path::Path::new(path)
2440 .extension()?
2441 .to_str()?
2442 .to_ascii_lowercase();
2443 matches!(
2444 extension.as_str(),
2445 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2446 )
2447 .then_some(extension)
2448}
2449
2450impl LifecycleHookHandler {
2451 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2452 Self::Command {
2453 command: command.into(),
2454 timeout_ms,
2455 }
2456 }
2457
2458 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2459 Self::Script {
2460 path: path.into(),
2461 runner,
2462 timeout_ms,
2463 }
2464 }
2465
2466 pub fn timeout_ms(&self) -> u64 {
2467 match self {
2468 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2469 }
2470 }
2471}
2472
2473#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2480pub struct RequestOverridesConfig {
2481 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2483 pub common: RequestScopeOverride,
2484 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2486 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2489 pub rules: Vec<ModelRequestRule>,
2490}
2491
2492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2494pub struct ModelRequestRule {
2495 pub model_pattern: String,
2497 #[serde(default, skip_serializing_if = "Option::is_none")]
2499 pub endpoint: Option<String>,
2500 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2502 pub scope: RequestScopeOverride,
2503}
2504
2505#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2507pub struct RequestScopeOverride {
2508 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2510 pub headers: BTreeMap<String, TemplateExpr>,
2511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2513 pub body_patch: Vec<BodyPatch>,
2514}
2515
2516impl RequestScopeOverride {
2517 pub fn is_empty(&self) -> bool {
2518 self.headers.is_empty() && self.body_patch.is_empty()
2519 }
2520}
2521
2522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2524pub struct BodyPatch {
2525 pub path: String,
2527 #[serde(default)]
2529 pub op: BodyPatchOp,
2530 #[serde(default, skip_serializing_if = "Option::is_none")]
2532 pub value: Option<PatchValue>,
2533}
2534
2535#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2537#[serde(rename_all = "snake_case")]
2538pub enum BodyPatchOp {
2539 #[default]
2540 Set,
2541 Remove,
2542}
2543
2544#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2546#[serde(untagged)]
2547pub enum PatchValue {
2548 Template(TemplateExpr),
2549 Json(Value),
2550}
2551
2552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2554#[serde(untagged)]
2555pub enum TemplateExpr {
2556 Literal(String),
2558 Structured(TemplateExprSpec),
2560}
2561
2562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2564#[serde(tag = "type", rename_all = "snake_case")]
2565pub enum TemplateExprSpec {
2566 Literal { value: String },
2568 EnvRef {
2570 name: String,
2571 #[serde(default, skip_serializing_if = "Option::is_none")]
2572 fallback: Option<String>,
2573 },
2574 Generated { generator: GeneratedValue },
2576 Format { template: String },
2578}
2579
2580#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2582#[serde(rename_all = "snake_case")]
2583pub enum GeneratedValue {
2584 Uuid,
2585 UnixMs,
2586}
2587
2588#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2590pub struct ToolsConfig {
2591 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2593 pub disabled: Vec<String>,
2594
2595 #[serde(default, flatten)]
2597 pub extra: BTreeMap<String, Value>,
2598}
2599
2600impl ToolsConfig {
2601 fn is_empty(&self) -> bool {
2602 self.disabled.is_empty() && self.extra.is_empty()
2603 }
2604}
2605
2606#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2608pub struct SkillsConfig {
2609 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2611 pub disabled: Vec<String>,
2612
2613 #[serde(default, flatten)]
2615 pub extra: BTreeMap<String, Value>,
2616}
2617
2618impl SkillsConfig {
2619 fn is_empty(&self) -> bool {
2620 self.disabled.is_empty() && self.extra.is_empty()
2621 }
2622}
2623
2624#[derive(Debug, Clone, Serialize, Deserialize)]
2629pub struct ImageFallbackHookConfig {
2630 #[serde(default = "default_true_hooks")]
2631 pub enabled: bool,
2632
2633 #[serde(default = "default_image_fallback_mode")]
2635 pub mode: String,
2636}
2637
2638impl Default for ImageFallbackHookConfig {
2639 fn default() -> Self {
2640 Self {
2641 enabled: default_true_hooks(),
2642 mode: default_image_fallback_mode(),
2643 }
2644 }
2645}
2646
2647fn default_image_fallback_mode() -> String {
2648 "placeholder".to_string()
2649}
2650
2651fn default_true_hooks() -> bool {
2652 false
2655}
2656
2657pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2669
2670#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2671pub struct OpenAIConfig {
2672 #[serde(default, skip_serializing)]
2676 pub api_key: String,
2677 #[serde(default, skip_serializing_if = "Option::is_none")]
2679 pub api_key_encrypted: Option<String>,
2680 #[serde(default, skip_serializing_if = "Option::is_none")]
2682 pub credential_ref: Option<crate::CredentialRef>,
2683 #[serde(skip)]
2688 pub api_key_from_env: bool,
2689 #[serde(skip_serializing_if = "Option::is_none")]
2691 pub base_url: Option<String>,
2692 #[serde(skip_serializing_if = "Option::is_none")]
2694 pub model: Option<String>,
2695 #[serde(default, skip_serializing_if = "Option::is_none")]
2698 pub fast_model: Option<String>,
2699 #[serde(default, skip_serializing_if = "Option::is_none")]
2702 pub vision_model: Option<String>,
2703 #[serde(skip_serializing_if = "Option::is_none")]
2705 pub reasoning_effort: Option<ReasoningEffort>,
2706
2707 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2714 pub responses_only_models: Vec<String>,
2715 #[serde(default, skip_serializing_if = "Option::is_none")]
2717 pub request_overrides: Option<RequestOverridesConfig>,
2718
2719 #[serde(default, flatten)]
2721 pub extra: BTreeMap<String, Value>,
2722}
2723
2724impl OpenAIConfig {
2725 pub fn explicit_prompt_cache_enabled(&self) -> bool {
2732 self.extra
2733 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
2734 .and_then(Value::as_bool)
2735 .unwrap_or(true)
2736 }
2737}
2738
2739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2751pub struct AnthropicConfig {
2752 #[serde(default, skip_serializing)]
2756 pub api_key: String,
2757 #[serde(default, skip_serializing_if = "Option::is_none")]
2759 pub api_key_encrypted: Option<String>,
2760 #[serde(default, skip_serializing_if = "Option::is_none")]
2762 pub credential_ref: Option<crate::CredentialRef>,
2763 #[serde(skip)]
2768 pub api_key_from_env: bool,
2769 #[serde(skip_serializing_if = "Option::is_none")]
2771 pub base_url: Option<String>,
2772 #[serde(skip_serializing_if = "Option::is_none")]
2774 pub model: Option<String>,
2775 #[serde(default, skip_serializing_if = "Option::is_none")]
2778 pub fast_model: Option<String>,
2779 #[serde(default, skip_serializing_if = "Option::is_none")]
2782 pub vision_model: Option<String>,
2783 #[serde(skip_serializing_if = "Option::is_none")]
2785 pub max_tokens: Option<u32>,
2786 #[serde(skip_serializing_if = "Option::is_none")]
2788 pub reasoning_effort: Option<ReasoningEffort>,
2789 #[serde(default, skip_serializing_if = "Option::is_none")]
2791 pub request_overrides: Option<RequestOverridesConfig>,
2792
2793 #[serde(default, skip_serializing_if = "Option::is_none")]
2810 pub thinking_replay_always: Option<bool>,
2811
2812 #[serde(default, flatten)]
2814 pub extra: BTreeMap<String, Value>,
2815}
2816
2817#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2828pub struct GeminiConfig {
2829 #[serde(default, skip_serializing)]
2833 pub api_key: String,
2834 #[serde(default, skip_serializing_if = "Option::is_none")]
2836 pub api_key_encrypted: Option<String>,
2837 #[serde(default, skip_serializing_if = "Option::is_none")]
2839 pub credential_ref: Option<crate::CredentialRef>,
2840 #[serde(skip)]
2845 pub api_key_from_env: bool,
2846 #[serde(skip_serializing_if = "Option::is_none")]
2848 pub base_url: Option<String>,
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 #[serde(default, skip_serializing_if = "Option::is_none")]
2865 pub request_overrides: Option<RequestOverridesConfig>,
2866
2867 #[serde(default, flatten)]
2869 pub extra: BTreeMap<String, Value>,
2870}
2871
2872#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2884pub struct CopilotConfig {
2885 #[serde(default)]
2887 pub enabled: bool,
2888 #[serde(default)]
2890 pub headless_auth: bool,
2891 #[serde(skip_serializing_if = "Option::is_none")]
2893 pub model: Option<String>,
2894 #[serde(default, skip_serializing_if = "Option::is_none")]
2897 pub fast_model: Option<String>,
2898 #[serde(default, skip_serializing_if = "Option::is_none")]
2901 pub vision_model: Option<String>,
2902 #[serde(skip_serializing_if = "Option::is_none")]
2904 pub reasoning_effort: Option<ReasoningEffort>,
2905
2906 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2915 pub responses_only_models: Vec<String>,
2916 #[serde(default, skip_serializing_if = "Option::is_none")]
2918 pub request_overrides: Option<RequestOverridesConfig>,
2919
2920 #[serde(default, flatten)]
2922 pub extra: BTreeMap<String, Value>,
2923}
2924
2925#[derive(Debug, Clone, Serialize, Deserialize)]
2930pub struct BodhiConfig {
2931 #[serde(default, skip_serializing)]
2933 pub api_key: String,
2934 #[serde(default, skip_serializing_if = "Option::is_none")]
2936 pub api_key_encrypted: Option<String>,
2937 #[serde(default, skip_serializing_if = "Option::is_none")]
2939 pub credential_ref: Option<crate::CredentialRef>,
2940 #[serde(skip_serializing_if = "Option::is_none")]
2942 pub base_url: Option<String>,
2943 #[serde(skip_serializing_if = "Option::is_none")]
2945 pub target_provider: Option<String>,
2946 #[serde(skip_serializing_if = "Option::is_none")]
2948 pub reasoning_effort: Option<ReasoningEffort>,
2949
2950 #[serde(default, flatten)]
2952 pub extra: BTreeMap<String, Value>,
2953}
2954
2955fn default_provider() -> String {
2957 "anthropic".to_string()
2958}
2959
2960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2992pub struct ProviderInstanceConfig {
2993 pub provider_type: String,
2998
2999 #[serde(default, skip_serializing_if = "Option::is_none")]
3001 pub label: Option<String>,
3002
3003 #[serde(default, skip_serializing)]
3005 pub api_key: String,
3006
3007 #[serde(default, skip_serializing_if = "Option::is_none")]
3010 pub api_key_encrypted: Option<String>,
3011
3012 #[serde(default, skip_serializing_if = "Option::is_none")]
3014 pub credential_ref: Option<crate::CredentialRef>,
3015
3016 #[serde(default, skip_serializing_if = "Option::is_none")]
3018 pub base_url: Option<String>,
3019
3020 #[serde(default, skip_serializing_if = "Option::is_none")]
3022 pub model: Option<String>,
3023
3024 #[serde(default, skip_serializing_if = "Option::is_none")]
3026 pub fast_model: Option<String>,
3027
3028 #[serde(default, skip_serializing_if = "Option::is_none")]
3030 pub vision_model: Option<String>,
3031
3032 #[serde(default, skip_serializing_if = "Option::is_none")]
3034 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3035
3036 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3038 pub responses_only_models: Vec<String>,
3039
3040 #[serde(default, skip_serializing_if = "Option::is_none")]
3042 pub request_overrides: Option<RequestOverridesConfig>,
3043
3044 #[serde(default = "default_true")]
3047 pub enabled: bool,
3048
3049 #[serde(default, flatten)]
3051 pub extra: BTreeMap<String, Value>,
3052}
3053
3054fn default_true() -> bool {
3055 true
3056}
3057
3058fn default_port() -> u16 {
3060 9562
3061}
3062
3063fn default_bind() -> String {
3065 "127.0.0.1".to_string()
3066}
3067
3068fn default_workers() -> usize {
3070 10
3071}
3072
3073fn default_data_dir() -> PathBuf {
3075 super::paths::bamboo_dir()
3076}
3077
3078#[derive(Debug, Clone, Serialize, Deserialize)]
3080pub struct ServerConfig {
3081 #[serde(default = "default_port")]
3083 pub port: u16,
3084
3085 #[serde(default = "default_bind")]
3087 pub bind: String,
3088
3089 pub static_dir: Option<PathBuf>,
3091
3092 #[serde(default = "default_workers")]
3094 pub workers: usize,
3095
3096 #[serde(default, skip_serializing_if = "Option::is_none")]
3104 pub tls: Option<TlsConfig>,
3105
3106 #[serde(default, flatten)]
3108 pub extra: BTreeMap<String, Value>,
3109}
3110
3111impl Default for ServerConfig {
3112 fn default() -> Self {
3113 Self {
3114 port: default_port(),
3115 bind: default_bind(),
3116 static_dir: None,
3117 workers: default_workers(),
3118 tls: None,
3119 extra: BTreeMap::new(),
3120 }
3121 }
3122}
3123
3124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3130pub struct TlsConfig {
3131 pub cert_file: PathBuf,
3133 pub key_file: PathBuf,
3135}
3136
3137#[derive(Debug, Clone, Serialize, Deserialize)]
3139pub struct ProxyAuth {
3140 pub username: String,
3142 pub password: String,
3144}
3145
3146fn parse_bool_env(value: &str) -> bool {
3150 matches!(
3151 value.trim().to_ascii_lowercase().as_str(),
3152 "1" | "true" | "yes" | "y" | "on"
3153 )
3154}
3155
3156fn expand_user_path(value: &str) -> PathBuf {
3157 let trimmed = value.trim();
3158 if let Some(rest) = trimmed.strip_prefix("~/") {
3159 if let Some(home) = dirs::home_dir() {
3160 return home.join(rest);
3161 }
3162 }
3163 PathBuf::from(trimmed)
3164}
3165
3166impl Default for Config {
3167 fn default() -> Self {
3168 Self::create_default()
3177 }
3178}
3179
3180#[derive(Debug, Clone, PartialEq, Eq)]
3182pub struct PromptSafeEnvVarEntry {
3183 pub name: String,
3184 pub secret: bool,
3185 pub description: Option<String>,
3186}
3187
3188static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3192
3193static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3194
3195fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3196 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3197}
3198
3199fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3200 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3201}
3202
3203impl Config {
3204 fn from_parts(
3205 values: ConfigValues,
3206 memory: Option<MemoryConfig>,
3207 subagents: SubagentsConfig,
3208 providers: ProviderConfigs,
3209 ) -> Self {
3210 Self {
3211 values,
3212 memory: crate::MemoryConfigModule(memory),
3213 subagents: crate::SubagentsConfigModule(subagents),
3214 providers: crate::ProviderConfigsModule(providers),
3215 recovery_status: None,
3216 }
3217 }
3218
3219 pub(crate) fn section_values(&self) -> ConfigValues {
3225 self.values.clone()
3226 }
3227
3228 pub(crate) fn from_section_parts(
3229 values: ConfigValues,
3230 memory: Option<MemoryConfig>,
3231 subagents: SubagentsConfig,
3232 providers: ProviderConfigs,
3233 ) -> Self {
3234 Self::from_parts(values, memory, subagents, providers)
3235 }
3236
3237 pub fn memory(&self) -> &Option<MemoryConfig> {
3239 &self.memory.0
3240 }
3241
3242 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3243 &mut self.memory.0
3244 }
3245
3246 pub fn subagents(&self) -> &SubagentsConfig {
3248 &self.subagents.0
3249 }
3250
3251 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3252 &mut self.subagents.0
3253 }
3254
3255 pub fn providers(&self) -> &ProviderConfigs {
3257 &self.providers.0
3258 }
3259
3260 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3261 &mut self.providers.0
3262 }
3263
3264 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3268 if self
3269 .default_provider_instance
3270 .as_ref()
3271 .is_some_and(|id| self.provider_instances.contains_key(id))
3272 {
3273 self.providers.0.clear_legacy_builtin_aliases();
3274 }
3275 }
3276
3277 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3283 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3284 let object = value
3285 .as_object_mut()
3286 .expect("ConfigRoot always serializes as a JSON object");
3287 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3288 object.insert(
3289 "subagents".to_string(),
3290 serde_json::to_value(self.subagents())?,
3291 );
3292 object.insert(
3293 "providers".to_string(),
3294 serde_json::to_value(self.providers())?,
3295 );
3296 Ok(value)
3297 }
3298
3299 pub fn new() -> Self {
3318 Self::from_data_dir(None)
3319 }
3320
3321 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3322 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3323 Ok(facade) => facade.effective_config(),
3324 Err(error) => {
3325 tracing::warn!(
3326 error = %error,
3327 "completed modular configuration is unavailable; refusing legacy-root fallback"
3328 );
3329 Self::create_default()
3330 }
3331 };
3332
3333 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3334 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3335 config.proxy_auth = None;
3336 }
3337 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3338 tracing::warn!(error = %error, "provider credential hydration unavailable");
3339 }
3340 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3341 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3342 }
3343 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3344 tracing::warn!(error = %error, "env credential hydration unavailable");
3345 for entry in &mut config.env_vars {
3346 if entry.secret {
3347 entry.value.clear();
3348 }
3349 }
3350 }
3351 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3352 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3353 config.clear_cluster_runtime_credentials();
3354 }
3355 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3356 tracing::warn!(error = %error, "notification credential hydration unavailable");
3357 config.notifications.ntfy.token = None;
3358 config.notifications.bark.device_key = None;
3359 }
3360 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3361 tracing::warn!(error = %error, "connect credential hydration unavailable");
3362 for platform in &mut config.connect.platforms {
3363 platform.token = None;
3364 platform.app_secret = None;
3365 }
3366 }
3367 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3368 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3369 config.clear_access_control_runtime_verifiers();
3370 }
3371 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3372 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3373 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3374 broker.token.clear();
3375 }
3376 }
3377 config.normalize_tool_settings();
3378 config.normalize_skill_settings();
3379 config.normalize_plugin_trust_settings();
3380 config.extra.remove("data_dir");
3381 if apply_env {
3382 config.apply_env_overrides();
3383 }
3384 if publish {
3385 config.publish_env_vars();
3386 }
3387 config
3388 }
3389
3390 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3399 let data_dir = data_dir
3401 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3402 .unwrap_or_else(default_data_dir);
3403
3404 match crate::modular_authority_boundary_present(&data_dir) {
3405 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3406 Ok(false) => {}
3407 Err(error) => {
3408 tracing::warn!(
3409 error = %error,
3410 "modular configuration marker is unavailable; refusing legacy-root fallback"
3411 );
3412 let mut config = Self::create_default();
3413 if apply_env {
3414 config.apply_env_overrides();
3415 }
3416 if publish {
3417 config.publish_env_vars();
3418 }
3419 return config;
3420 }
3421 }
3422
3423 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3428 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3429 .map_err(|error| {
3430 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3431 error
3432 })
3433 .is_ok();
3434 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3435 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3436 .map_err(|error| {
3437 tracing::warn!(error = %error, "cluster credential migration unavailable");
3438 error
3439 })
3440 .is_ok();
3441
3442 match crate::modular_authority_boundary_present(&data_dir) {
3447 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3448 Ok(false) => {}
3449 Err(error) => {
3450 tracing::warn!(
3451 error = %error,
3452 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3453 );
3454 let mut config = Self::create_default();
3455 if apply_env {
3456 config.apply_env_overrides();
3457 }
3458 if publish {
3459 config.publish_env_vars();
3460 }
3461 return config;
3462 }
3463 }
3464
3465 let config_path = data_dir.join("config.json");
3466
3467 let mut config = if config_path.exists() {
3468 if let Ok(content) = std::fs::read_to_string(&config_path) {
3469 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3470 tracing::warn!(
3480 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3481 e
3482 );
3483 let quarantine_path = quarantine_corrupt_config(&config_path);
3484 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3485 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3486 .or_else(|| {
3487 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3488 (cfg, ConfigRecoverySource::Backup { generation })
3489 })
3490 })
3491 .unwrap_or_else(|| {
3492 tracing::warn!(
3493 "Could not salvage and no usable config.json.bak; using defaults"
3494 );
3495 (Self::create_default(), ConfigRecoverySource::Defaults)
3496 });
3497 recovered.recovery_status = Some(ConfigRecoveryStatus {
3498 source,
3499 quarantine_path,
3500 confirmed: false,
3501 });
3502 recovered
3503 })
3504 } else {
3505 Self::create_default()
3506 }
3507 } else {
3508 Self::create_default()
3509 };
3510
3511 let mut memory_module = config.memory.clone();
3517 match memory_module.load_sync(&data_dir) {
3518 Ok(true) => config.memory = memory_module,
3519 Ok(false) => {}
3520 Err(error) => tracing::warn!(
3521 "Failed to load memory.json; using legacy config.json memory: {error}"
3522 ),
3523 }
3524 let mut subagents_module = config.subagents.clone();
3525 match subagents_module.load_sync(&data_dir) {
3526 Ok(true) => config.subagents = subagents_module,
3527 Ok(false) => {}
3528 Err(error) => tracing::warn!(
3529 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3530 ),
3531 }
3532 if provider_mcp_ready {
3533 let mut providers_module = config.providers.clone();
3534 match providers_module.load_sync(&data_dir) {
3535 Ok(true) => config.providers = providers_module,
3536 Ok(false) => {}
3537 Err(error) => tracing::warn!(
3538 "Failed to load providers.json; using legacy config.json providers: {error}"
3539 ),
3540 }
3541 }
3542
3543 config.hydrate_proxy_auth_from_encrypted();
3545 if provider_mcp_ready {
3546 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3547 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3548 config.proxy_auth = None;
3549 }
3550 }
3551 config.hydrate_provider_api_keys_from_encrypted();
3553 config.hydrate_provider_instance_api_keys_from_encrypted();
3555 config.hydrate_mcp_secrets_from_encrypted();
3557 if provider_mcp_ready {
3558 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3559 tracing::warn!(error = %error, "provider credential hydration unavailable");
3560 }
3561 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3562 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3563 }
3564 }
3565 config.hydrate_env_vars_from_encrypted();
3567 if provider_mcp_ready {
3568 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3569 tracing::warn!(error = %error, "env credential hydration unavailable");
3570 for entry in &mut config.env_vars {
3571 if entry.secret {
3572 entry.value.clear();
3573 }
3574 }
3575 }
3576 }
3577 if cluster_ready {
3582 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3583 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3584 config.clear_cluster_runtime_credentials();
3585 }
3586 } else {
3587 config.clear_cluster_runtime_credentials();
3588 }
3589 config.hydrate_broker_token_from_encrypted();
3591 config.hydrate_notifications_from_encrypted();
3593 if provider_mcp_ready {
3594 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3595 tracing::warn!(error = %error, "notification credential hydration unavailable");
3596 config.notifications.ntfy.token = None;
3597 config.notifications.bark.device_key = None;
3598 }
3599 } else {
3600 config.notifications.ntfy.token = None;
3605 config.notifications.bark.device_key = None;
3606 }
3607 config.merge_connect_config(&data_dir);
3612 scrub_legacy_connect_from_config_backups(&data_dir);
3622 config.hydrate_connect_platform_tokens_from_encrypted();
3624 if provider_mcp_ready {
3625 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3626 tracing::warn!(error = %error, "connect credential hydration unavailable");
3627 for platform in &mut config.connect.platforms {
3628 platform.token = None;
3629 platform.app_secret = None;
3630 }
3631 }
3632 }
3633 if provider_mcp_ready {
3634 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3635 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3636 config.clear_access_control_runtime_verifiers();
3637 }
3638 } else {
3639 config.clear_access_control_runtime_verifiers();
3640 }
3641 config.normalize_tool_settings();
3642 config.normalize_skill_settings();
3643 config.normalize_plugin_trust_settings();
3644
3645 config.extra.remove("data_dir");
3648
3649 if apply_env {
3653 config.apply_env_overrides();
3654 }
3655
3656 if publish {
3660 config.publish_env_vars();
3661 }
3662
3663 config
3664 }
3665
3666 fn apply_env_overrides(&mut self) {
3670 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3671 if let Ok(port) = port.parse() {
3672 self.server.port = port;
3673 }
3674 }
3675
3676 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3677 self.server.bind = bind;
3678 }
3679
3680 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3685 let provider = provider.trim().to_string();
3686 if !provider.is_empty() {
3687 self.provider = provider.clone();
3688 if !self.provider_instances.is_empty() {
3689 let selected = self
3690 .provider_instances
3691 .contains_key(&provider)
3692 .then_some(provider.clone())
3693 .or_else(|| {
3694 let mut matching = self
3695 .provider_instances
3696 .iter()
3697 .filter(|(_, instance)| {
3698 instance.enabled && instance.provider_type == provider
3699 })
3700 .map(|(id, _)| id.clone())
3701 .collect::<Vec<_>>();
3702 matching.sort();
3703 matching.into_iter().next()
3704 });
3705 self.default_provider_instance = selected;
3706 }
3707 }
3708 }
3709
3710 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
3711 self.headless_auth = parse_bool_env(&headless);
3712 }
3713
3714 if let Ok(project_prompt_injection) =
3715 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
3716 {
3717 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3718 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
3719 }
3720
3721 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
3722 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3723 memory.relevant_recall = parse_bool_env(&relevant_recall);
3724 }
3725
3726 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
3727 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3728 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
3729 }
3730
3731 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
3732 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3733 memory.project_first_dream = parse_bool_env(&project_first_dream);
3734 }
3735
3736 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
3743 let key = key.trim();
3744 if !key.is_empty() {
3745 if self.provider_instances.is_empty() {
3746 let openai = self
3747 .providers
3748 .openai
3749 .get_or_insert_with(OpenAIConfig::default);
3750 openai.api_key = key.to_string();
3751 openai.api_key_from_env = true;
3752 } else {
3753 self.apply_provider_instance_env_key("openai", key);
3754 }
3755 }
3756 }
3757 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
3758 let key = key.trim();
3759 if !key.is_empty() {
3760 if self.provider_instances.is_empty() {
3761 let anthropic = self
3762 .providers
3763 .anthropic
3764 .get_or_insert_with(AnthropicConfig::default);
3765 anthropic.api_key = key.to_string();
3766 anthropic.api_key_from_env = true;
3767 } else {
3768 self.apply_provider_instance_env_key("anthropic", key);
3769 }
3770 }
3771 }
3772 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
3773 let key = key.trim();
3774 if !key.is_empty() {
3775 if self.provider_instances.is_empty() {
3776 let gemini = self
3777 .providers
3778 .gemini
3779 .get_or_insert_with(GeminiConfig::default);
3780 gemini.api_key = key.to_string();
3781 gemini.api_key_from_env = true;
3782 } else {
3783 self.apply_provider_instance_env_key("gemini", key);
3784 }
3785 }
3786 }
3787 }
3788
3789 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
3790 for instance in self.provider_instances.values_mut().filter(|instance| {
3791 instance.provider_type == provider_type
3792 && crate::provider_instance_api_key_from_env(instance)
3793 }) {
3794 instance.api_key = key.to_string();
3795 instance.api_key_encrypted = None;
3796 }
3797 }
3798
3799 pub fn apply_runtime_env_overrides(&mut self) {
3804 self.apply_env_overrides();
3805 }
3806
3807 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
3813 Self::from_data_dir_impl(data_dir, true, true)
3814 }
3815
3816 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
3820 Self::from_data_dir_impl(data_dir, false, true)
3821 }
3822
3823 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
3830 Self::from_data_dir_impl(data_dir, false, false)
3831 }
3832
3833 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
3861 let connect_path = data_dir.join("connect.json");
3862 match std::fs::read_to_string(&connect_path) {
3863 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
3864 Ok(connect) => {
3865 let legacy_key_present = !connect_config_is_empty(&self.connect);
3866 if legacy_key_present {
3867 tracing::warn!(
3868 "config.json still has a legacy `connect` key alongside \
3869 connect.json; connect.json takes precedence — dropping the \
3870 stale key from config.json now"
3871 );
3872 }
3873 self.connect = connect;
3874 if legacy_key_present {
3875 strip_legacy_connect_key_from_config_json(data_dir);
3876 }
3877 }
3878 Err(e) => {
3879 tracing::error!(
3880 "Failed to parse {:?} ({}); continuing with an empty (inert) \
3881 connect config instead of falling back to a legacy config.json copy",
3882 connect_path,
3883 e
3884 );
3885 quarantine_corrupt_connect(&connect_path);
3886 self.connect = ConnectConfig::default();
3887 }
3888 },
3889 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
3890 if !connect_config_is_empty(&self.connect) {
3891 tracing::info!(
3892 "Migrating legacy `connect` config from config.json to a \
3893 standalone connect.json"
3894 );
3895 strip_legacy_connect_key_from_config_json(data_dir);
3899 if let Err(e) = save_connect_config(&self.connect, data_dir) {
3900 tracing::error!("Failed to write connect.json during migration: {}", e);
3901 }
3902 }
3903 }
3904 Err(e) => {
3905 tracing::error!(
3906 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
3907 connect_path,
3908 e
3909 );
3910 self.connect = ConnectConfig::default();
3911 }
3912 }
3913 }
3914
3915 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
3918 serde_json::from_str::<Config>(content).map(|mut config| {
3919 config.hydrate_proxy_auth_from_encrypted();
3920 config.hydrate_provider_api_keys_from_encrypted();
3921 config.hydrate_provider_instance_api_keys_from_encrypted();
3922 config.hydrate_mcp_secrets_from_encrypted();
3923 config.hydrate_env_vars_from_encrypted();
3924 config.hydrate_cluster_fabric_from_encrypted();
3925 config.hydrate_broker_token_from_encrypted();
3926 config.hydrate_notifications_from_encrypted();
3927 config.hydrate_connect_platform_tokens_from_encrypted();
3928 config.normalize_tool_settings();
3929 config.normalize_skill_settings();
3930 config
3931 })
3932 }
3933
3934 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
3940 let config_path = data_dir.join("config.json");
3941 for gen in 0..BAK_GENERATIONS {
3942 let backup = backup_path_for(&config_path, gen);
3943 let Ok(content) = std::fs::read_to_string(&backup) else {
3944 continue;
3945 };
3946 match Self::parse_and_hydrate(&content) {
3947 Ok(config) => {
3948 tracing::info!("Recovered configuration from {:?}", backup);
3949 return Some((config, gen));
3950 }
3951 Err(e) => {
3952 tracing::warn!(
3953 "Backup {:?} is unparseable ({}); trying an older generation",
3954 backup,
3955 e
3956 );
3957 }
3958 }
3959 }
3960 None
3961 }
3962
3963 const SALVAGE_MAX_KEYS: usize = 512;
3969
3970 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
3997 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4000 let corrupt_obj = corrupt.as_object()?;
4001 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4002 tracing::warn!(
4003 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4004 corrupt_obj.len(),
4005 Self::SALVAGE_MAX_KEYS
4006 );
4007 return None;
4008 }
4009
4010 let mut base = Self::load_backup(data_dir)
4014 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4015 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4016 let base_obj = base.as_object_mut()?;
4017
4018 let mut salvaged: Vec<String> = Vec::new();
4019 for (key, value) in corrupt_obj {
4020 let previous = base_obj.insert(key.clone(), value.clone());
4021 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4025 salvaged.push(key.clone());
4026 } else {
4027 match previous {
4028 Some(prev) => {
4029 base_obj.insert(key.clone(), prev);
4030 }
4031 None => {
4032 base_obj.remove(key);
4033 }
4034 }
4035 }
4036 }
4037
4038 tracing::warn!(
4039 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4040 last-known-good/default value",
4041 salvaged.len(),
4042 salvaged.join(", ")
4043 );
4044
4045 let rebuilt = serde_json::to_string(&base).ok()?;
4049 Self::parse_and_hydrate(&rebuilt)
4050 .ok()
4051 .map(|config| (config, salvaged))
4052 }
4053
4054 pub fn get_model(&self) -> Option<String> {
4062 if self.features.provider_model_ref {
4063 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4064 return Some(model_ref.model.clone());
4065 }
4066 }
4067 let provider = self.effective_default_provider();
4068 if let Some(instance) = self.provider_instances.get(provider) {
4069 return instance
4070 .model
4071 .clone()
4072 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4073 }
4074 match provider {
4075 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4076 "anthropic" => self
4077 .providers
4078 .anthropic
4079 .as_ref()
4080 .and_then(|c| c.model.clone()),
4081 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4082 "copilot" => Some(
4083 self.providers
4084 .copilot
4085 .as_ref()
4086 .and_then(|c| c.model.clone())
4087 .unwrap_or_else(|| "gpt-4o".to_string()),
4088 ),
4089 _ => None,
4090 }
4091 }
4092
4093 pub fn get_fast_model(&self) -> Option<String> {
4101 if self.features.provider_model_ref {
4102 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4103 return Some(model_ref.model.clone());
4104 }
4105 }
4106 let provider = self.effective_default_provider();
4107 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4108 instance.fast_model.clone()
4109 } else {
4110 match provider {
4111 "openai" => self
4112 .providers
4113 .openai
4114 .as_ref()
4115 .and_then(|c| c.fast_model.clone()),
4116 "anthropic" => self
4117 .providers
4118 .anthropic
4119 .as_ref()
4120 .and_then(|c| c.fast_model.clone()),
4121 "gemini" => self
4122 .providers
4123 .gemini
4124 .as_ref()
4125 .and_then(|c| c.fast_model.clone()),
4126 "copilot" => self
4127 .providers
4128 .copilot
4129 .as_ref()
4130 .and_then(|c| c.fast_model.clone()),
4131 _ => None,
4132 }
4133 };
4134 fast.or_else(|| self.get_model())
4135 }
4136
4137 pub fn get_task_summary_model(&self) -> Option<String> {
4145 if self.features.provider_model_ref {
4146 if let Some(model_ref) = self
4147 .defaults
4148 .as_ref()
4149 .and_then(|d| d.task_summary.as_ref())
4150 .or_else(|| {
4151 self.defaults
4152 .as_ref()
4153 .and_then(|d| d.memory_background.as_ref())
4154 })
4155 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4156 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4157 {
4158 return Some(model_ref.model.clone());
4159 }
4160 }
4161
4162 self.get_memory_background_model()
4163 .or_else(|| self.get_model())
4164 }
4165
4166 pub fn get_memory_background_model(&self) -> Option<String> {
4178 if self.features.provider_model_ref {
4179 if let Some(model_ref) = self
4180 .defaults
4181 .as_ref()
4182 .and_then(|d| d.memory_background.as_ref())
4183 {
4184 return Some(model_ref.model.clone());
4185 }
4186 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4187 return Some(model_ref.model.clone());
4188 }
4189 }
4190 let configured = self
4191 .memory
4192 .as_ref()
4193 .and_then(|memory| memory.background_model.as_ref())
4194 .map(|value| value.trim())
4195 .filter(|value| !value.is_empty())
4196 .map(ToString::to_string);
4197 configured.or_else(|| {
4198 let provider = self.effective_default_provider();
4199 if let Some(instance) = self.provider_instances.get(provider) {
4200 return instance.fast_model.clone();
4201 }
4202 match provider {
4203 "openai" => self
4204 .providers
4205 .openai
4206 .as_ref()
4207 .and_then(|c| c.fast_model.clone()),
4208 "anthropic" => self
4209 .providers
4210 .anthropic
4211 .as_ref()
4212 .and_then(|c| c.fast_model.clone()),
4213 "gemini" => self
4214 .providers
4215 .gemini
4216 .as_ref()
4217 .and_then(|c| c.fast_model.clone()),
4218 "copilot" => self
4219 .providers
4220 .copilot
4221 .as_ref()
4222 .and_then(|c| c.fast_model.clone()),
4223 _ => None,
4224 }
4225 })
4226 }
4227
4228 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4236 let raw = self
4237 .default_work_area
4238 .as_ref()
4239 .and_then(|config| config.path.as_ref())
4240 .map(|value| value.trim())
4241 .filter(|value| !value.is_empty())?;
4242
4243 let candidate = expand_user_path(raw);
4244 if candidate.is_absolute() {
4245 let canonical = std::fs::canonicalize(&candidate).ok();
4246 return canonical
4247 .as_ref()
4248 .filter(|path| path.is_dir())
4249 .map(|_| candidate.clone())
4250 .or_else(|| candidate.is_dir().then_some(candidate));
4251 }
4252
4253 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4254 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4255 canonical
4256 .as_ref()
4257 .filter(|path| path.is_dir())
4258 .map(|_| from_bamboo_dir.clone())
4259 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4260 .or_else(|| candidate.is_dir().then_some(candidate))
4261 }
4262
4263 pub fn get_vision_model(&self) -> Option<String> {
4268 let provider = self.effective_default_provider();
4269 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4270 instance.vision_model.clone()
4271 } else {
4272 match provider {
4273 "openai" => self
4274 .providers
4275 .openai
4276 .as_ref()
4277 .and_then(|c| c.vision_model.clone()),
4278 "anthropic" => self
4279 .providers
4280 .anthropic
4281 .as_ref()
4282 .and_then(|c| c.vision_model.clone()),
4283 "gemini" => self
4284 .providers
4285 .gemini
4286 .as_ref()
4287 .and_then(|c| c.vision_model.clone()),
4288 "copilot" => self
4289 .providers
4290 .copilot
4291 .as_ref()
4292 .and_then(|c| c.vision_model.clone()),
4293 _ => None,
4294 }
4295 };
4296 vision.or_else(|| self.get_model())
4297 }
4298
4299 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4301 self.reasoning_effort_for_key(self.effective_default_provider())
4302 }
4303
4304 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4314 let trimmed = key.trim();
4315 if trimmed.is_empty() {
4316 return None;
4317 }
4318
4319 if let Some(instance) = self.provider_instances.get(trimmed) {
4321 return instance.reasoning_effort;
4322 }
4323
4324 match trimmed {
4326 "openai" => self
4327 .providers
4328 .openai
4329 .as_ref()
4330 .and_then(|c| c.reasoning_effort),
4331 "anthropic" => self
4332 .providers
4333 .anthropic
4334 .as_ref()
4335 .and_then(|c| c.reasoning_effort),
4336 "gemini" => self
4337 .providers
4338 .gemini
4339 .as_ref()
4340 .and_then(|c| c.reasoning_effort),
4341 "copilot" => self
4342 .providers
4343 .copilot
4344 .as_ref()
4345 .and_then(|c| c.reasoning_effort),
4346 "bodhi" => self
4347 .providers
4348 .bodhi
4349 .as_ref()
4350 .and_then(|c| c.reasoning_effort),
4351 _ => None,
4352 }
4353 }
4354
4355 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4357 self.tools
4358 .disabled
4359 .iter()
4360 .map(|name| name.trim())
4361 .filter(|name| !name.is_empty())
4362 .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
4363 .collect()
4364 }
4365
4366 pub fn normalize_tool_settings(&mut self) {
4368 self.tools.disabled = self.disabled_tool_names().into_iter().collect();
4369 }
4370
4371 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4373 self.skills
4374 .disabled
4375 .iter()
4376 .map(|id| id.trim())
4377 .filter(|id| !id.is_empty())
4378 .map(|id| id.to_string())
4379 .collect()
4380 }
4381
4382 pub fn normalize_skill_settings(&mut self) {
4384 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4385 }
4386
4387 pub fn normalize_plugin_trust_settings(&mut self) {
4395 self.plugin_trust.trusted_hosts = self
4396 .plugin_trust
4397 .trusted_hosts
4398 .iter()
4399 .map(|entry| entry.trim().to_ascii_lowercase())
4400 .filter(|entry| !entry.is_empty())
4401 .collect();
4402 }
4403
4404 pub fn effective_default_provider(&self) -> &str {
4409 self.default_provider_instance
4410 .as_deref()
4411 .unwrap_or(&self.provider)
4412 }
4413
4414 pub fn has_provider_instances(&self) -> bool {
4416 !self.provider_instances.is_empty()
4417 }
4418
4419 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4421 self.env_vars
4422 .iter()
4423 .filter(|e| !e.value.trim().is_empty())
4424 .map(|e| (e.name.clone(), e.value.clone()))
4425 .collect()
4426 }
4427
4428 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4429 self.env_vars
4430 .iter()
4431 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4432 .map(|entry| PromptSafeEnvVarEntry {
4433 name: entry.name.clone(),
4434 secret: entry.secret,
4435 description: entry
4436 .description
4437 .as_ref()
4438 .map(|value| value.trim().to_string())
4439 .filter(|value| !value.is_empty()),
4440 })
4441 .collect()
4442 }
4443
4444 pub fn publish_env_vars(&self) {
4446 let map = self.env_vars_as_map();
4447
4448 #[cfg(any(test, feature = "test-utils"))]
4449 if crate::test_support::env_vars_cache_override_is_active() {
4450 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4451 return;
4452 }
4453
4454 let mut env_guard = env_vars_cache().write().recover_poison();
4455 *env_guard = map;
4456
4457 let prompt_safe = self.prompt_safe_env_vars();
4458 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4459 *prompt_guard = prompt_safe;
4460 }
4461
4462 pub fn current_env_vars() -> HashMap<String, String> {
4464 #[cfg(any(test, feature = "test-utils"))]
4465 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4466 return env_vars;
4467 }
4468
4469 env_vars_cache().read().recover_poison().clone()
4470 }
4471
4472 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4474 #[cfg(any(test, feature = "test-utils"))]
4475 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4476 return env_vars;
4477 }
4478
4479 prompt_safe_env_vars_cache().read().recover_poison().clone()
4480 }
4481
4482 fn create_default() -> Self {
4484 Self::from_parts(
4485 ConfigValues {
4486 http_proxy: String::new(),
4487 https_proxy: String::new(),
4488 proxy_auth: None,
4489 proxy_auth_encrypted: None,
4490 proxy_auth_credential_ref: None,
4491 headless_auth: false,
4492 run_budget: RunBudgetConfig::default(),
4493 stream_timeout: StreamTimeoutConfig::default(),
4494 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4495 provider: default_provider(),
4496 provider_instances: HashMap::new(),
4497 default_provider_instance: None,
4498 server: ServerConfig::default(),
4499 keyword_masking: KeywordMaskingConfig::default(),
4500 anthropic_model_mapping: AnthropicModelMapping::default(),
4501 gemini_model_mapping: GeminiModelMapping::default(),
4502 hooks: HooksConfig::default(),
4503 lifecycle_hooks: LifecycleHooksConfig::default(),
4504 tools: ToolsConfig::default(),
4505 skills: SkillsConfig::default(),
4506 env_vars: Vec::new(),
4507 default_work_area: None,
4508 access_control: None,
4509 features: FeatureFlags::default(),
4510 defaults: None,
4511 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4512 notifications: NotificationsConfig::default(),
4513 connect: ConnectConfig::default(),
4514 plugin_trust: PluginTrustConfig::default(),
4515 extra: BTreeMap::new(),
4516 },
4517 None,
4518 SubagentsConfig::default(),
4519 ProviderConfigs::default(),
4520 )
4521 }
4522
4523 pub fn server_addr(&self) -> String {
4525 format!("{}:{}", self.server.bind, self.server.port)
4526 }
4527
4528 pub fn save(&self) -> Result<()> {
4530 self.save_to_dir(default_data_dir())
4531 }
4532
4533 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4535 self.memory.save_sync(data_dir)
4536 }
4537
4538 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4540 self.subagents.save_sync(data_dir)
4541 }
4542
4543 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4546 let mut config = self.clone();
4547 config.clear_legacy_provider_aliases_for_instance_mode();
4548 config.refresh_provider_api_keys_encrypted()?;
4549 config.providers.save_sync(data_dir)
4550 }
4551
4552 pub(crate) fn prepare_provider_transaction_documents(
4556 &self,
4557 provider_document: &[u8],
4558 ) -> Result<(Vec<u8>, Vec<u8>)> {
4559 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4560 anyhow::bail!(
4561 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4562 status.source
4563 );
4564 }
4565
4566 let mut to_save = self.clone();
4567 to_save.clear_legacy_provider_aliases_for_instance_mode();
4568 to_save.extra.remove("data_dir");
4569 to_save.extra.remove("model");
4570 to_save.refresh_encrypted_secrets()?;
4571 to_save.ensure_provider_instance_credentials_isolated()?;
4572 to_save.sanitize_mcp_credential_refs_for_disk();
4573 to_save.sanitize_env_vars_for_disk();
4574 to_save.sanitize_notifications_for_disk();
4575 to_save.sanitize_cluster_fabric_for_disk();
4576 to_save.assign_connect_platform_ids();
4577 to_save.normalize_tool_settings();
4578 to_save.normalize_skill_settings();
4579
4580 let mut root = durable_root_value(to_save.values.clone())
4581 .context("Failed to serialize root config DTO to JSON")?;
4582 if let Some(object) = root.as_object_mut() {
4583 object.remove("connect");
4584 }
4585 let root = serde_json::to_vec_pretty(&root)?;
4586
4587 let mut providers = to_save.providers.0.clone();
4588 macro_rules! sanitize_provider {
4589 ($field:ident) => {
4590 if let Some(provider) = providers.$field.as_mut() {
4591 if !provider.api_key.trim().is_empty()
4592 && !provider.api_key_from_env
4593 && provider.credential_ref.is_none()
4594 {
4595 anyhow::bail!(
4596 "provider secret requires credential transaction before persistence"
4597 );
4598 }
4599 provider.api_key_encrypted = None;
4600 }
4601 };
4602 }
4603 sanitize_provider!(openai);
4604 sanitize_provider!(anthropic);
4605 sanitize_provider!(gemini);
4606 if let Some(provider) = providers.bodhi.as_mut() {
4607 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4608 anyhow::bail!("provider secret requires credential transaction before persistence");
4609 }
4610 provider.api_key_encrypted = None;
4611 }
4612
4613 let existing_provider_value = if provider_document.is_empty() {
4614 None
4615 } else {
4616 Some(
4617 serde_json::from_slice::<Value>(provider_document)
4618 .context("provider metadata document is invalid")?,
4619 )
4620 };
4621 let provider_value = match existing_provider_value {
4622 Some(Value::Object(mut envelope))
4623 if envelope.contains_key("schema_version")
4624 || envelope.contains_key("revision")
4625 || envelope.contains_key("data") =>
4626 {
4627 let revision = envelope
4628 .get("revision")
4629 .and_then(Value::as_u64)
4630 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4631 let schema_version = envelope
4632 .get("schema_version")
4633 .and_then(Value::as_u64)
4634 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4635 if schema_version != 1 || !envelope.contains_key("data") {
4636 anyhow::bail!("provider revision envelope is unsupported");
4637 }
4638 let revision = revision
4639 .checked_add(1)
4640 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4641 envelope.insert("revision".into(), Value::from(revision));
4642 envelope.insert("data".into(), serde_json::to_value(providers)?);
4643 Value::Object(envelope)
4644 }
4645 Some(_) | None => serde_json::to_value(providers)?,
4646 };
4647 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4648 }
4649
4650 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4654 self.recovery_status.as_ref()
4655 }
4656
4657 pub fn confirm_recovery(&mut self) {
4663 if let Some(status) = self.recovery_status.as_mut() {
4664 status.confirmed = true;
4665 }
4666 }
4667
4668 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4676 if self.recovery_status.is_none() {
4677 anyhow::bail!("No pending config-corruption recovery to confirm");
4678 }
4679 self.confirm_recovery();
4680 self.save_to_dir(data_dir)?;
4681 self.recovery_status = None;
4682 Ok(())
4683 }
4684
4685 pub fn assign_connect_platform_ids(&mut self) {
4701 for platform in &mut self.connect.platforms {
4702 if platform.id.is_none() {
4703 platform.id = Some(uuid::Uuid::new_v4().to_string());
4704 }
4705 }
4706 }
4707
4708 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
4720 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4721 anyhow::bail!(
4722 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
4723 has not been confirmed; the corrupt original is preserved at {:?}. Call \
4724 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
4725 status.source,
4726 status.quarantine_path,
4727 );
4728 }
4729 if self.proxy_auth_credential_ref.is_none()
4730 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
4731 {
4732 anyhow::bail!(
4733 "proxy auth requires the isolated credential transaction before persistence"
4734 );
4735 }
4736 if self.env_vars.iter().any(|entry| {
4737 entry.secret
4738 && entry.credential_ref.is_none()
4739 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
4740 }) {
4741 anyhow::bail!(
4742 "secret env vars require the isolated credential transaction before persistence"
4743 );
4744 }
4745 if [
4746 (
4747 &self.notifications.ntfy.token,
4748 &self.notifications.ntfy.token_encrypted,
4749 &self.notifications.ntfy.credential_ref,
4750 ),
4751 (
4752 &self.notifications.bark.device_key,
4753 &self.notifications.bark.device_key_encrypted,
4754 &self.notifications.bark.credential_ref,
4755 ),
4756 ]
4757 .iter()
4758 .any(|(plaintext, ciphertext, reference)| {
4759 reference.is_none()
4760 && (plaintext
4761 .as_deref()
4762 .is_some_and(|value| !value.trim().is_empty())
4763 || ciphertext.is_some())
4764 }) {
4765 anyhow::bail!(
4766 "notification secrets require the isolated credential transaction before persistence"
4767 );
4768 }
4769
4770 if crate::modular_authority_boundary_present(&data_dir)
4771 .context("Failed to inspect modular configuration authority boundary")?
4772 {
4773 crate::ConfigFacade::open_or_migrate(&data_dir)
4774 .context("Failed to recover modular configuration before persistence")?;
4775 crate::persist_facade_effective_config(&data_dir, self)
4776 .context("Failed to persist modular configuration sections")?;
4777 return Ok(());
4778 }
4779
4780 if crate::section_layout_is_active(&data_dir)
4781 .context("Failed to inspect modular configuration layout")?
4782 {
4783 crate::persist_facade_effective_config(&data_dir, self)
4784 .context("Failed to persist modular configuration sections")?;
4785 return Ok(());
4786 }
4787
4788 let path = data_dir.join("config.json");
4789
4790 if let Some(parent) = path.parent() {
4791 std::fs::create_dir_all(parent)
4792 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
4793 }
4794
4795 let mut to_save = self.clone();
4796 to_save.clear_legacy_provider_aliases_for_instance_mode();
4797 to_save.extra.remove("data_dir");
4799 to_save.extra.remove("model");
4801 to_save.refresh_encrypted_secrets()?;
4804 to_save.ensure_provider_instance_credentials_isolated()?;
4805 to_save.sanitize_mcp_credential_refs_for_disk();
4806 to_save.sanitize_env_vars_for_disk();
4807 to_save.sanitize_notifications_for_disk();
4808 to_save.sanitize_cluster_fabric_for_disk();
4809 to_save.assign_connect_platform_ids();
4810 to_save.normalize_tool_settings();
4811 to_save.normalize_skill_settings();
4812
4813 let mut config_value = durable_root_value(to_save.values.clone())
4823 .context("Failed to serialize root config DTO to JSON")?;
4824 if let Some(obj) = config_value.as_object_mut() {
4825 obj.remove("connect");
4826 }
4827 let content = serde_json::to_string_pretty(&config_value)
4828 .context("Failed to serialize config to JSON")?;
4829
4830 to_save.memory.save_sync(&data_dir)?;
4837 to_save.subagents.save_sync(&data_dir)?;
4838 to_save.providers.save_sync(&data_dir)?;
4839
4840 if path.exists()
4847 && std::fs::read_to_string(&path)
4848 .ok()
4849 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
4850 {
4851 rotate_backups(&path, BAK_GENERATIONS);
4855 let backup = backup_path_for(&path, 0);
4856 let backup_result = std::fs::read(&path).and_then(|bytes| {
4857 let mut value: Value = serde_json::from_slice(&bytes)
4858 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
4859 if sanitize_ref_backed_mcp_json(&mut value) {
4860 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
4861 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
4862 })?;
4863 write_atomic(&backup, &sanitized)
4864 } else {
4865 std::fs::copy(&path, &backup).map(|_| ())
4866 }
4867 });
4868 if let Err(e) = backup_result {
4869 tracing::warn!("Failed to back up config.json before save: {}", e);
4870 }
4871 scrub_ref_backed_mcp_from_config_backups(&path);
4872 }
4873
4874 write_atomic(&path, content.as_bytes())
4875 .with_context(|| format!("Failed to write config file: {:?}", path))?;
4876
4877 save_connect_config(&to_save.connect, &data_dir)?;
4878
4879 Ok(())
4880 }
4881}
4882
4883fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
4888 let Some(object) = root.as_object_mut() else {
4889 return false;
4890 };
4891 let Some(mcp) = (if object.contains_key("mcpServers") {
4892 object.get_mut("mcpServers")
4893 } else {
4894 object.get_mut("mcp")
4895 }) else {
4896 return false;
4897 };
4898 let mut changed = false;
4899 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
4900 for server in servers {
4901 if let Some(object) = server.as_object_mut() {
4902 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
4903 {
4904 changed |= sanitize_ref_backed_mcp_transport(transport);
4905 }
4906 }
4907 }
4908 } else if let Some(servers) = mcp.as_object_mut() {
4909 for server in servers.values_mut() {
4910 if let Some(object) = server.as_object_mut() {
4911 changed |= sanitize_ref_backed_mcp_transport(object);
4912 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
4913 {
4914 changed |= sanitize_ref_backed_mcp_transport(transport);
4915 }
4916 }
4917 }
4918 }
4919 changed
4920}
4921
4922fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
4923 let mut changed = false;
4924 let env_names = object
4925 .get("env_credential_refs")
4926 .and_then(Value::as_object)
4927 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
4928 .unwrap_or_default();
4929 for name in env_names {
4930 for field in ["env", "env_encrypted"] {
4931 if object
4932 .get_mut(field)
4933 .and_then(Value::as_object_mut)
4934 .and_then(|values| values.remove(&name))
4935 .is_some()
4936 {
4937 changed = true;
4938 }
4939 }
4940 }
4941 let header_names = object
4942 .get("header_credential_refs")
4943 .and_then(Value::as_object)
4944 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
4945 .unwrap_or_default();
4946 for name in header_names {
4947 for field in ["headers", "headers_encrypted"] {
4948 if object
4949 .get_mut(field)
4950 .and_then(Value::as_object_mut)
4951 .and_then(|values| values.remove(&name))
4952 .is_some()
4953 {
4954 changed = true;
4955 }
4956 }
4957 }
4958 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
4959 for header in headers {
4960 let Some(header) = header.as_object_mut() else {
4961 continue;
4962 };
4963 if header.get("credential_ref").is_some_and(Value::is_string) {
4964 changed |= header.remove("value").is_some();
4965 changed |= header.remove("value_encrypted").is_some();
4966 }
4967 }
4968 }
4969 changed
4970}
4971
4972fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
4973 for generation in 0..BAK_GENERATIONS {
4974 let backup = backup_path_for(path, generation);
4975 let Ok(bytes) = std::fs::read(&backup) else {
4976 continue;
4977 };
4978 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
4979 continue;
4980 };
4981 if sanitize_ref_backed_mcp_json(&mut value) {
4982 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
4983 if let Err(error) = write_atomic(&backup, &sanitized) {
4984 tracing::warn!(
4985 "Failed to scrub MCP credentials from {:?}: {}",
4986 backup,
4987 error
4988 );
4989 }
4990 }
4991 }
4992 }
4993}
4994
4995fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5005 let path = data_dir.join("connect.json");
5006 if connect_config_is_empty(connect) && !path.exists() {
5007 return Ok(());
5008 }
5009
5010 if path.exists() {
5011 let backup = path.with_extension("json.bak");
5012 if let Err(e) = std::fs::copy(&path, &backup) {
5013 tracing::warn!("Failed to back up connect.json before save: {}", e);
5014 }
5015 }
5016
5017 let content = serde_json::to_string_pretty(connect)
5018 .context("Failed to serialize connect config to JSON")?;
5019 write_atomic(&path, content.as_bytes())
5020 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5021 Ok(())
5022}
5023
5024fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5042 let config_path = data_dir.join("config.json");
5043 let content = match std::fs::read_to_string(&config_path) {
5044 Ok(content) => content,
5045 Err(e) => {
5046 tracing::error!(
5047 "Failed to read config.json to strip legacy `connect` key: {}",
5048 e
5049 );
5050 return;
5051 }
5052 };
5053 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5054 Ok(value) => value,
5055 Err(e) => {
5056 tracing::error!(
5057 "Failed to parse config.json to strip legacy `connect` key: {}",
5058 e
5059 );
5060 return;
5061 }
5062 };
5063 let Some(obj) = value.as_object_mut() else {
5064 return;
5065 };
5066 if obj.remove("connect").is_none() {
5067 return;
5070 }
5071 let rewritten = match serde_json::to_string_pretty(&value) {
5072 Ok(rewritten) => rewritten,
5073 Err(e) => {
5074 tracing::error!(
5075 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5076 e
5077 );
5078 return;
5079 }
5080 };
5081 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5082 tracing::error!(
5083 "Failed to write config.json after stripping legacy `connect` key: {}",
5084 e
5085 );
5086 }
5087}
5088
5089fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5126 let config_path = data_dir.join("config.json");
5127 for gen in 0..BAK_GENERATIONS {
5128 let backup = backup_path_for(&config_path, gen);
5129 let content = match std::fs::read_to_string(&backup) {
5130 Ok(content) => content,
5131 Err(e) => {
5132 if e.kind() != std::io::ErrorKind::NotFound {
5133 tracing::warn!(
5134 "Failed to read {:?} while scanning for legacy connect data ({}); \
5135 leaving it untouched",
5136 backup,
5137 e
5138 );
5139 }
5140 continue;
5141 }
5142 };
5143 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5144 Ok(value) => value,
5145 Err(e) => {
5146 tracing::warn!(
5147 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5148 ({}); left untouched (never deleted)",
5149 backup,
5150 e
5151 );
5152 continue;
5153 }
5154 };
5155 let Some(obj) = value.as_object_mut() else {
5156 continue;
5159 };
5160 if obj.remove("connect").is_none() {
5161 continue;
5164 }
5165 let rewritten = match serde_json::to_string_pretty(&value) {
5166 Ok(rewritten) => rewritten,
5167 Err(e) => {
5168 tracing::error!(
5169 "Failed to serialize {:?} after stripping legacy connect data: {}",
5170 backup,
5171 e
5172 );
5173 continue;
5174 }
5175 };
5176 match write_atomic(&backup, rewritten.as_bytes()) {
5177 Ok(()) => tracing::info!(
5178 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5179 backup
5180 ),
5181 Err(e) => tracing::error!(
5182 "Failed to write {:?} after stripping legacy connect data: {}",
5183 backup,
5184 e
5185 ),
5186 }
5187 }
5188}
5189
5190fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5205 let backup = connect_path.with_extension("json.bak");
5206 match std::fs::rename(connect_path, &backup) {
5207 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5208 Err(e) => {
5209 tracing::warn!(
5210 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5211 backup,
5212 e
5213 );
5214 if let Err(e) = std::fs::copy(connect_path, &backup) {
5215 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5216 return;
5217 }
5218 if let Err(e) = std::fs::remove_file(connect_path) {
5219 tracing::error!(
5220 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5221 original {:?}: {}",
5222 backup,
5223 connect_path,
5224 e
5225 );
5226 }
5227 }
5228 }
5229}
5230
5231const QUARANTINE_KEEP: usize = 5;
5234
5235fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5244 let nanos = std::time::SystemTime::now()
5245 .duration_since(std::time::UNIX_EPOCH)
5246 .map(|d| d.as_nanos())
5247 .unwrap_or(0);
5248 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5252 let mut dedup = 1u32;
5253 while quarantine.exists() {
5254 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5255 dedup += 1;
5256 }
5257 let result = match std::fs::copy(config_path, &quarantine) {
5258 Ok(_) => {
5259 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5260 Some(quarantine)
5261 }
5262 Err(e) => {
5263 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5264 None
5265 }
5266 };
5267 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5268 result
5269}
5270
5271fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5274 let Some(dir) = config_path.parent() else {
5275 return;
5276 };
5277 let prefix = "config.json.corrupted.";
5278 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5279 Ok(entries) => entries
5280 .filter_map(|e| e.ok())
5281 .map(|e| e.path())
5282 .filter(|p| {
5283 p.file_name()
5284 .and_then(|n| n.to_str())
5285 .is_some_and(|n| n.starts_with(prefix))
5286 })
5287 .collect(),
5288 Err(_) => return,
5289 };
5290 if quarantines.len() <= keep {
5291 return;
5292 }
5293 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5295 let remove = quarantines.len() - keep;
5296 for stale in quarantines.into_iter().take(remove) {
5297 if let Err(e) = std::fs::remove_file(&stale) {
5298 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5299 }
5300 }
5301}
5302
5303const BAK_GENERATIONS: usize = 3;
5306
5307fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5309 if gen == 0 {
5310 config_path.with_extension("json.bak")
5311 } else {
5312 config_path.with_extension(format!("json.bak.{gen}"))
5313 }
5314}
5315
5316fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5322 for gen in (1..generations).rev() {
5323 let from = backup_path_for(config_path, gen - 1);
5324 let to = backup_path_for(config_path, gen);
5325 if from.exists() {
5326 if let Err(e) = std::fs::rename(&from, &to) {
5327 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5328 }
5329 }
5330 }
5331}
5332
5333pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5334 crate::config_store::AtomicFileStore::new(path)
5335 .write_bytes_without_backup(content)
5336 .map_err(|error| match error {
5337 crate::config_store::ConfigStoreError::Io(error) => error,
5338 other => std::io::Error::other(other),
5339 })
5340}
5341
5342#[cfg(test)]
5343mod tests {
5344 use super::*;
5345 use std::ffi::OsString;
5346 use std::path::PathBuf;
5347 use std::sync::Mutex;
5348 use std::time::{SystemTime, UNIX_EPOCH};
5349
5350 #[test]
5351 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5352 let input = serde_json::json!({
5353 "disabled": ["bash"],
5354 "plugin_runtime": {
5355 "timeout_ms": 5_000,
5356 "sandbox": true
5357 },
5358 "future_flag": "enabled"
5359 });
5360
5361 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5362 assert_eq!(config.disabled, vec!["bash"]);
5363 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5364 assert_eq!(config.extra["future_flag"], "enabled");
5365 assert_eq!(serde_json::to_value(config).unwrap(), input);
5366 }
5367
5368 #[test]
5369 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5370 let input = serde_json::json!({
5371 "tool_extension": {
5372 "mode": "strict",
5373 "options": ["one", "two"]
5374 }
5375 });
5376 let config: Config = serde_json::from_value(serde_json::json!({
5377 "tools": input.clone()
5378 }))
5379 .unwrap();
5380
5381 assert!(config.tools.disabled.is_empty());
5382 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5383 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5384
5385 let temp_home = TempHome::new();
5386 config.save_to_dir(temp_home.path.clone()).unwrap();
5387 let persisted: Value =
5388 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5389 .unwrap();
5390 assert_eq!(persisted["tools"], input);
5391 }
5392
5393 #[test]
5394 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5395 let input = serde_json::json!({
5396 "external_catalog": {
5397 "path": "/opt/bamboo/skills",
5398 "refresh": false
5399 },
5400 "schema_version": 2
5401 });
5402
5403 let config: Config = serde_json::from_value(serde_json::json!({
5404 "skills": input.clone()
5405 }))
5406 .unwrap();
5407 assert!(config.skills.disabled.is_empty());
5408 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5409 assert_eq!(config.skills.extra["schema_version"], 2);
5410 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5411
5412 let temp_home = TempHome::new();
5413 config.save_to_dir(temp_home.path.clone()).unwrap();
5414 let persisted: Value =
5415 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5416 .unwrap();
5417 assert_eq!(persisted["skills"], input);
5418 }
5419
5420 #[test]
5421 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5422 let config: Config = serde_json::from_value(serde_json::json!({
5423 "hooks": {
5424 "image_fallback": {"enabled": true, "mode": "placeholder"}
5425 },
5426 "lifecycle_hooks": {
5427 "enabled": true,
5428 "PreToolUse": [{
5429 "matcher": "bash|write_file",
5430 "hooks": [{"type": "command", "command": "guard.sh"}]
5431 }],
5432 "SessionStart": [{
5433 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5434 }]
5435 }
5436 }))
5437 .expect("lifecycle hook config should deserialize");
5438
5439 assert!(config.lifecycle_hooks.enabled);
5440 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5441 assert!(
5442 config.lifecycle_hooks.pre_tool_use[0].enabled,
5443 "legacy groups without an enabled flag remain active"
5444 );
5445 assert_eq!(
5446 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5447 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5448 );
5449 assert_eq!(
5450 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5451 25
5452 );
5453
5454 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5455 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5456 assert_eq!(
5457 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5458 "bash|write_file"
5459 );
5460 assert_eq!(
5461 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5462 "command"
5463 );
5464 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5465 .get("enabled")
5466 .is_none());
5467 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5468 .get("timeout_ms")
5469 .is_none());
5470 assert!(json.get("hooks").is_some());
5471 }
5472
5473 #[test]
5474 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5475 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5476 "type": "script",
5477 "path": ".bamboo/hooks/check.js"
5478 }))
5479 .expect("script lifecycle hook should deserialize");
5480
5481 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5482 assert!(matches!(
5483 handler,
5484 LifecycleHookHandler::Script {
5485 runner: LifecycleScriptRunner::Auto,
5486 ..
5487 }
5488 ));
5489 let json = serde_json::to_value(handler).expect("script hook should serialize");
5490 assert_eq!(json["type"], "script");
5491 assert!(json.get("timeout_ms").is_none());
5492 assert!(json.get("runner").is_none());
5493 }
5494
5495 #[test]
5496 fn script_runner_support_is_extension_aware() {
5497 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5498 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5499 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5500 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5501 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5502 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5503 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5504 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5505 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5506 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5507 }
5508
5509 #[test]
5510 fn script_runner_names_round_trip_through_config_json() {
5511 for (runner, name) in [
5512 (LifecycleScriptRunner::Auto, "auto"),
5513 (LifecycleScriptRunner::Node, "node"),
5514 (LifecycleScriptRunner::Bun, "bun"),
5515 (LifecycleScriptRunner::Python, "python"),
5516 (LifecycleScriptRunner::Bash, "bash"),
5517 (LifecycleScriptRunner::PowerShell, "powershell"),
5518 (LifecycleScriptRunner::Cmd, "cmd"),
5519 ] {
5520 let json = serde_json::to_value(runner).unwrap();
5521 assert_eq!(json, name);
5522 assert_eq!(
5523 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5524 runner
5525 );
5526 }
5527 }
5528
5529 #[test]
5530 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5531 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5532 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5533 let json = serde_json::to_value(&config).unwrap();
5534 assert!(json.get("lifecycle_hooks").is_none());
5535 }
5536
5537 #[test]
5538 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5539 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5540 .expect("legacy config without stream_timeout should load");
5541 let values = ConfigValues::from(root);
5542
5543 assert_eq!(
5544 values.stream_timeout,
5545 StreamTimeoutConfig {
5546 transport_idle_timeout_secs: 120,
5547 first_semantic_timeout_secs: 600,
5548 semantic_idle_timeout_secs: 600,
5549 }
5550 );
5551 values
5552 .stream_timeout
5553 .validate()
5554 .expect("defaults are valid");
5555 }
5556
5557 #[test]
5558 fn stream_timeout_round_trips_through_persistence_dto() {
5559 let values = ConfigValues {
5560 stream_timeout: StreamTimeoutConfig {
5561 transport_idle_timeout_secs: 45,
5562 first_semantic_timeout_secs: 900,
5563 semantic_idle_timeout_secs: 300,
5564 },
5565 ..ConfigValues::default()
5566 };
5567
5568 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5569 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5570 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5571 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5572
5573 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5574 assert_eq!(
5575 ConfigValues::from(root).stream_timeout,
5576 StreamTimeoutConfig {
5577 transport_idle_timeout_secs: 45,
5578 first_semantic_timeout_secs: 900,
5579 semantic_idle_timeout_secs: 300,
5580 }
5581 );
5582 }
5583
5584 #[test]
5585 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
5586 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5587 "provider_type": "openai",
5588 "enabled": true
5589 }))
5590 .unwrap();
5591 let mut config = Config::default();
5592 config.values.provider = "gemini".to_string();
5593 config
5594 .provider_instances
5595 .insert("work".to_string(), instance);
5596 config.default_provider_instance = Some("work".to_string());
5597 config.providers_mut().openai = Some(OpenAIConfig::default());
5598
5599 let json = serde_json::to_value(&config).unwrap();
5600 assert_eq!(json["provider"], "gemini");
5601 assert!(json["providers"]["openai"].is_object());
5602 assert_eq!(json["default_provider_instance"], "work");
5603
5604 let round_trip: Config = serde_json::from_value(json).unwrap();
5605 assert_eq!(round_trip.provider, "gemini");
5606 assert_eq!(
5607 round_trip.default_provider_instance.as_deref(),
5608 Some("work")
5609 );
5610 assert!(round_trip.provider_instances.contains_key("work"));
5611 assert!(round_trip.providers().openai.is_some());
5612 }
5613
5614 #[test]
5615 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
5616 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
5617 let dir = tempfile::tempdir().unwrap();
5618 let mut config = Config::default();
5619 config.values.provider = "anthropic".to_string();
5620 config.provider_instances.insert(
5621 "work".to_string(),
5622 serde_json::from_value(serde_json::json!({
5623 "provider_type": "openai",
5624 "model": "gpt-instance",
5625 "enabled": true,
5626 "future_instance_metadata": "preserved"
5627 }))
5628 .unwrap(),
5629 );
5630 config.default_provider_instance = Some("work".to_string());
5631 config.features.provider_model_ref = true;
5632 config.providers_mut().openai = Some(OpenAIConfig::default());
5633 config.providers_mut().anthropic = Some(AnthropicConfig::default());
5634 config
5635 .providers_mut()
5636 .extra
5637 .insert("provider".to_string(), serde_json::json!("anthropic"));
5638 config.providers_mut().extra.insert(
5639 "future_provider".to_string(),
5640 serde_json::json!({"kept": true}),
5641 );
5642
5643 let (root_bytes, provider_bytes) =
5644 config.prepare_provider_transaction_documents(&[]).unwrap();
5645 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5646 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5647 assert!(root.get("provider").is_none());
5648 assert!(root.get("providers").is_none());
5649 assert_eq!(root["default_provider_instance"], "work");
5650 assert_eq!(
5651 root["provider_instances"]["work"]["future_instance_metadata"],
5652 "preserved"
5653 );
5654 assert!(providers.get("provider").is_none());
5655 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
5656 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
5657 }
5658 assert_eq!(providers["future_provider"]["kept"], true);
5659
5660 config.save_to_dir(dir.path().to_path_buf()).unwrap();
5661 let saved_root: Value =
5662 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
5663 .unwrap();
5664 let saved_providers: Value =
5665 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
5666 .unwrap();
5667 assert!(saved_root.get("provider").is_none());
5668 assert!(saved_root.get("providers").is_none());
5669 assert_eq!(saved_root["default_provider_instance"], "work");
5670 assert!(saved_providers.get("openai").is_none());
5671 assert!(saved_providers.get("anthropic").is_none());
5672 assert!(saved_providers.get("provider").is_none());
5673 assert_eq!(saved_providers["future_provider"]["kept"], true);
5674
5675 config.providers_mut().gemini = Some(GeminiConfig::default());
5676 config.save_providers_to_dir(dir.path()).unwrap();
5677 let provider_only: Value =
5678 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
5679 .unwrap();
5680 assert!(provider_only.get("gemini").is_none());
5681 assert!(provider_only.get("provider").is_none());
5682 assert_eq!(provider_only["future_provider"]["kept"], true);
5683 }
5684
5685 #[test]
5686 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
5687 let mut config = Config::default();
5688 config.values.provider = "openai".to_string();
5689 config.providers_mut().openai = Some(OpenAIConfig {
5690 model: Some("gpt-legacy".to_string()),
5691 ..OpenAIConfig::default()
5692 });
5693 config.provider_instances.insert(
5694 "work".to_string(),
5695 serde_json::from_value(serde_json::json!({
5696 "provider_type": "copilot",
5697 "enabled": true
5698 }))
5699 .unwrap(),
5700 );
5701 config.default_provider_instance = Some("openai".to_string());
5702
5703 let (root_bytes, provider_bytes) =
5704 config.prepare_provider_transaction_documents(&[]).unwrap();
5705 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5706 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5707 assert_eq!(root["provider"], "openai");
5708 assert_eq!(root["default_provider_instance"], "openai");
5709 assert!(root["provider_instances"]["work"].is_object());
5710 assert_eq!(providers["openai"]["model"], "gpt-legacy");
5711 }
5712
5713 #[test]
5714 fn persistence_keeps_legacy_provider_without_instance_default() {
5715 let values = ConfigValues {
5716 provider: "gemini".to_string(),
5717 ..ConfigValues::default()
5718 };
5719
5720 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5721 assert_eq!(json["provider"], "gemini");
5722 }
5723
5724 #[test]
5725 fn stream_timeout_rejects_zero_and_unbounded_values() {
5726 for (field, invalid) in [
5727 ("transport_idle_timeout_secs", 0),
5728 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
5729 ("semantic_idle_timeout_secs", 0),
5730 ] {
5731 let mut timeout = serde_json::json!({});
5732 timeout[field] = serde_json::json!(invalid);
5733 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
5734 .expect_err("invalid timeout must be rejected");
5735 assert!(error.to_string().contains("stream timeout must be between"));
5736 }
5737 }
5738
5739 struct EnvVarGuard {
5740 key: &'static str,
5741 previous: Option<OsString>,
5742 }
5743
5744 impl EnvVarGuard {
5745 fn set(key: &'static str, value: &str) -> Self {
5746 let previous = std::env::var_os(key);
5747 std::env::set_var(key, value);
5748 Self { key, previous }
5749 }
5750
5751 fn unset(key: &'static str) -> Self {
5752 let previous = std::env::var_os(key);
5753 std::env::remove_var(key);
5754 Self { key, previous }
5755 }
5756 }
5757
5758 impl Drop for EnvVarGuard {
5759 fn drop(&mut self) {
5760 match &self.previous {
5761 Some(value) => std::env::set_var(self.key, value),
5762 None => std::env::remove_var(self.key),
5763 }
5764 }
5765 }
5766
5767 #[test]
5768 fn run_budget_config_merge_is_tighten_only_per_field() {
5769 let config_default = RunBudgetConfig {
5770 max_total_tokens: Some(100_000),
5771 max_tool_calls: Some(500),
5772 max_subagents: Some(10),
5773 };
5774
5775 assert_eq!(
5777 config_default.merged_with_override(None),
5778 config_default,
5779 "no override falls back to the config default entirely"
5780 );
5781
5782 let tighten_one = RunBudgetConfig {
5785 max_total_tokens: Some(5_000),
5786 max_tool_calls: None,
5787 max_subagents: None,
5788 };
5789 let merged = config_default.merged_with_override(Some(&tighten_one));
5790 assert_eq!(merged.max_total_tokens, Some(5_000));
5791 assert_eq!(merged.max_tool_calls, Some(500));
5792 assert_eq!(merged.max_subagents, Some(10));
5793
5794 let loosen_attempt = RunBudgetConfig {
5797 max_total_tokens: Some(999_999_999),
5798 max_tool_calls: Some(10_000),
5799 max_subagents: Some(1_000),
5800 };
5801 assert_eq!(
5802 config_default.merged_with_override(Some(&loosen_attempt)),
5803 config_default,
5804 "looser per-request values must be clamped to the config ceiling"
5805 );
5806
5807 let empty_override = RunBudgetConfig::default();
5811 assert_eq!(
5812 config_default.merged_with_override(Some(&empty_override)),
5813 config_default,
5814 "an all-absent override body keeps every configured ceiling"
5815 );
5816
5817 let unlimited_default = RunBudgetConfig::default();
5821 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
5822 assert_eq!(merged.max_total_tokens, Some(5_000));
5823 assert_eq!(merged.max_tool_calls, None);
5824 assert_eq!(merged.max_subagents, None);
5825 }
5826
5827 #[test]
5828 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
5829 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
5830 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
5831 assert_eq!(RunBudgetConfig::default().max_subagents, None);
5832
5833 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
5834 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
5835 assert_eq!(cfg.max_total_tokens, Some(250_000));
5836 assert_eq!(
5837 cfg.max_tool_calls, None,
5838 "absent field defaults to unlimited"
5839 );
5840 assert_eq!(cfg.max_subagents, Some(3));
5841
5842 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
5846 assert_eq!(empty, "{}");
5847 }
5848
5849 #[test]
5850 fn subagents_config_without_remote_placements_deserializes_empty() {
5851 let json = r#"{ "max_concurrent": 4 }"#;
5854 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5855 assert_eq!(cfg.max_concurrent, Some(4));
5856 assert!(cfg.remote_placements.is_empty());
5857 let back = serde_json::to_string(&cfg).unwrap();
5859 assert!(
5860 !back.contains("remote_placements"),
5861 "empty vec is skipped: {back}"
5862 );
5863 }
5864
5865 #[test]
5866 fn remote_actor_placement_round_trips() {
5867 let json = r#"{
5868 "remote_placements": [
5869 {
5870 "role": "explorer",
5871 "endpoint": "wss://gpu-host:8443",
5872 "token_env": "WORKER_TOKEN",
5873 "ca_cert_file": "/etc/bamboo/worker.pem"
5874 },
5875 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
5876 ]
5877 }"#;
5878 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5879 assert_eq!(cfg.remote_placements.len(), 2);
5880 let p0 = &cfg.remote_placements[0];
5881 assert_eq!(p0.role, "explorer");
5882 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
5883 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
5884 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
5885 let p1 = &cfg.remote_placements[1];
5887 assert_eq!(p1.role, "writer");
5888 assert!(p1.token_env.is_none());
5889 assert!(p1.ca_cert_file.is_none());
5890
5891 let back = serde_json::to_string(&cfg).unwrap();
5892 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5893 assert_eq!(cfg, reparsed, "round-trip is stable");
5894 assert!(!back.contains("\"token_env\":null"));
5895 assert!(!back.contains("\"ca_cert_file\":null"));
5896 }
5897
5898 #[test]
5899 fn subagents_config_without_schedulable_placements_deserializes_empty() {
5900 let json = r#"{ "max_concurrent": 4 }"#;
5903 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5904 assert!(cfg.schedulable_placements.is_empty());
5905 let back = serde_json::to_string(&cfg).unwrap();
5907 assert!(
5908 !back.contains("schedulable_placements"),
5909 "empty vec is skipped: {back}"
5910 );
5911 }
5912
5913 #[test]
5914 fn schedulable_placement_round_trips() {
5915 let json = r#"{
5916 "schedulable_placements": [
5917 {
5918 "role": "explorer",
5919 "pool": "gpu-pool",
5920 "registry_url": "https://control-plane:9562",
5921 "token_env": "WORKER_TOKEN",
5922 "ca_cert_file": "/etc/bamboo/worker.pem"
5923 },
5924 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
5925 ]
5926 }"#;
5927 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5928 assert_eq!(cfg.schedulable_placements.len(), 2);
5929 let p0 = &cfg.schedulable_placements[0];
5930 assert_eq!(p0.role, "explorer");
5931 assert_eq!(p0.pool, "gpu-pool");
5932 assert_eq!(p0.registry_url, "https://control-plane:9562");
5933 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
5934 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
5935 let p1 = &cfg.schedulable_placements[1];
5937 assert_eq!(p1.role, "writer");
5938 assert_eq!(p1.pool, "cpu-pool");
5939 assert!(p1.token_env.is_none());
5940 assert!(p1.ca_cert_file.is_none());
5941
5942 let back = serde_json::to_string(&cfg).unwrap();
5943 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5944 assert_eq!(cfg, reparsed, "round-trip is stable");
5945 assert!(!back.contains("\"token_env\":null"));
5946 assert!(!back.contains("\"ca_cert_file\":null"));
5947 }
5948
5949 #[test]
5950 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
5951 let json = r#"{ "max_concurrent": 4 }"#;
5955 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
5956 assert!(cfg.mcp_role_allowlist.is_empty());
5957 let back = serde_json::to_string(&cfg).unwrap();
5959 assert!(
5960 !back.contains("mcp_role_allowlist"),
5961 "empty vec is skipped: {back}"
5962 );
5963 }
5964
5965 #[test]
5966 fn mcp_role_allowlist_entry_round_trips() {
5967 let json = r#"{
5968 "mcp_role_allowlist": [
5969 { "role": "researcher", "tools": ["fetch_url"] },
5970 { "role": "sandboxed", "tools": [] }
5971 ]
5972 }"#;
5973 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
5974 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
5975 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
5976 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
5977 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
5980 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
5981
5982 let back = serde_json::to_string(&cfg).unwrap();
5983 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
5984 assert_eq!(cfg, reparsed, "round-trip is stable");
5985 }
5986
5987 #[test]
5988 fn server_config_without_tls_field_deserializes_back_compat() {
5989 let server: ServerConfig = serde_json::from_value(serde_json::json!({
5992 "port": 9562,
5993 "bind": "127.0.0.1"
5994 }))
5995 .expect("legacy server config without tls should deserialize");
5996
5997 assert_eq!(server.tls, None);
5998 assert_eq!(server.port, 9562);
5999 assert_eq!(server.bind, "127.0.0.1");
6000 }
6001
6002 #[test]
6003 fn server_config_omits_tls_when_none() {
6004 let server = ServerConfig::default();
6007 let value = serde_json::to_value(&server).expect("server config should serialize");
6008 let obj = value
6009 .as_object()
6010 .expect("server config serializes to object");
6011 assert!(
6012 !obj.contains_key("tls"),
6013 "tls must be omitted when None, got: {value}"
6014 );
6015 }
6016
6017 #[test]
6018 fn server_config_with_tls_roundtrips() {
6019 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6020 "port": 9562,
6021 "bind": "0.0.0.0",
6022 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6023 }))
6024 .expect("server config with tls should deserialize");
6025
6026 let tls = server.tls.clone().expect("tls should be Some");
6027 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6028 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6029
6030 let value = serde_json::to_value(&server).expect("serialize");
6032 assert!(value.as_object().unwrap().contains_key("tls"));
6033 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6034 assert_eq!(back.tls, server.tls);
6035 }
6036
6037 #[test]
6038 fn access_control_without_devices_field_deserializes_back_compat() {
6039 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6042 "password_enabled": true,
6043 "password_hash": "deadbeef",
6044 "password_salt": "01020304",
6045 }))
6046 .expect("legacy access_control without devices should deserialize");
6047
6048 assert!(access.devices.is_empty());
6049 assert!(access.password_enabled);
6050 }
6051
6052 #[test]
6053 fn access_control_omits_devices_when_empty() {
6054 let access = AccessControlConfig {
6057 password_enabled: true,
6058 repair_required: false,
6059 password_hash: Some("deadbeef".to_string()),
6060 password_salt: Some("01020304".to_string()),
6061 password_credential_ref: None,
6062 password_configured: false,
6063 updated_at: None,
6064 devices: Vec::new(),
6065 };
6066 let value = serde_json::to_value(&access).expect("serialize");
6067 let obj = value.as_object().expect("object");
6068 assert!(
6069 !obj.contains_key("devices"),
6070 "devices must be omitted when empty, got: {value}"
6071 );
6072 }
6073
6074 #[test]
6075 fn access_control_with_devices_roundtrips() {
6076 let device = DeviceCredential {
6077 device_id: "bamboo_0123456789ab".to_string(),
6078 label: "iPhone 15".to_string(),
6079 token_hash: "abcd".to_string(),
6080 token_salt: "ef01".to_string(),
6081 token_credential_ref: None,
6082 token_configured: false,
6083 created_at: "2026-06-23T00:00:00Z".to_string(),
6084 last_used_at: None,
6085 revoked: false,
6086 };
6087 let access = AccessControlConfig {
6088 password_enabled: true,
6089 repair_required: false,
6090 password_hash: Some("deadbeef".to_string()),
6091 password_salt: Some("01020304".to_string()),
6092 password_credential_ref: None,
6093 password_configured: false,
6094 updated_at: None,
6095 devices: vec![device.clone()],
6096 };
6097 let value = serde_json::to_value(&access).expect("serialize");
6098 assert!(value.as_object().unwrap().contains_key("devices"));
6099 assert!(value["devices"][0].get("token_hash").is_none());
6100 assert!(value["devices"][0].get("token_salt").is_none());
6101 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6102 assert_eq!(back.devices[0].device_id, device.device_id);
6103 assert!(back.devices[0].token_hash.is_empty());
6104 assert!(back.devices[0].token_salt.is_empty());
6105 }
6106
6107 #[test]
6108 fn reasoning_effort_for_key_resolves_instance_id() {
6109 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6113 "provider_type": "copilot",
6114 "reasoning_effort": "high",
6115 }))
6116 .expect("instance config should deserialize");
6117
6118 let mut config = Config::create_default();
6119 config
6120 .provider_instances
6121 .insert("copilot-work".to_string(), instance);
6122
6123 assert_eq!(
6124 config.reasoning_effort_for_key("copilot-work"),
6125 Some(ReasoningEffort::High),
6126 );
6127 }
6128
6129 #[test]
6130 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6131 let mut config = Config::create_default();
6133 config.providers.bodhi = Some(
6134 serde_json::from_value(serde_json::json!({
6135 "reasoning_effort": "xhigh",
6136 }))
6137 .expect("bodhi config should deserialize"),
6138 );
6139
6140 assert_eq!(
6141 config.reasoning_effort_for_key("bodhi"),
6142 Some(ReasoningEffort::Xhigh),
6143 );
6144 }
6145
6146 #[test]
6147 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6148 let mut config = Config::create_default();
6149 config.providers.openai = Some(
6150 serde_json::from_value(serde_json::json!({
6151 "api_key": "sk-test",
6152 "reasoning_effort": "low",
6153 }))
6154 .expect("openai config should deserialize"),
6155 );
6156
6157 assert_eq!(
6158 config.reasoning_effort_for_key("openai"),
6159 Some(ReasoningEffort::Low),
6160 );
6161 }
6162
6163 #[test]
6164 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6165 let config = Config::create_default();
6166 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6167 assert_eq!(config.reasoning_effort_for_key(" "), None);
6168 }
6169
6170 struct TempHome {
6171 path: PathBuf,
6172 }
6173
6174 impl TempHome {
6175 fn new() -> Self {
6176 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6188 std::sync::atomic::AtomicU64::new(0);
6189 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6190 let nanos = SystemTime::now()
6191 .duration_since(UNIX_EPOCH)
6192 .expect("clock should be after unix epoch")
6193 .as_nanos();
6194 let path = std::env::temp_dir().join(format!(
6195 "chat-core-config-test-{}-{}-{}",
6196 std::process::id(),
6197 nanos,
6198 unique
6199 ));
6200 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6201 Self { path }
6202 }
6203
6204 fn set_config_json(&self, content: &str) {
6205 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6208 std::fs::write(self.path.join("config.json"), content)
6209 .expect("failed to write config.json");
6210 }
6211 }
6212
6213 impl Drop for TempHome {
6214 fn drop(&mut self) {
6215 let _ = std::fs::remove_dir_all(&self.path);
6216 }
6217 }
6218
6219 fn env_lock() -> &'static Mutex<()> {
6223 crate::test_support::env_cache_lock()
6224 }
6225
6226 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6228 env_lock().lock().unwrap_or_else(|poisoned| {
6229 poisoned.into_inner()
6231 })
6232 }
6233
6234 #[test]
6235 fn parse_bool_env_true_values() {
6236 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6237 assert!(parse_bool_env(value), "value {value:?} should be true");
6238 }
6239 }
6240
6241 #[test]
6242 fn parse_bool_env_false_values() {
6243 for value in ["0", "false", "no", "off", "", " "] {
6244 assert!(!parse_bool_env(value), "value {value:?} should be false");
6245 }
6246 }
6247
6248 #[test]
6249 fn config_new_ignores_http_proxy_env_vars() {
6250 let _lock = env_lock_acquire();
6251 let temp_home = TempHome::new();
6252 temp_home.set_config_json(
6253 r#"{
6254 "http_proxy": "",
6255 "https_proxy": ""
6256}"#,
6257 );
6258
6259 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6260 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6261
6262 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6263
6264 assert!(
6265 config.http_proxy.is_empty(),
6266 "config should ignore HTTP_PROXY env var"
6267 );
6268 assert!(
6269 config.https_proxy.is_empty(),
6270 "config should ignore HTTPS_PROXY env var"
6271 );
6272 }
6273
6274 #[test]
6275 fn config_new_loads_config_when_proxy_fields_omitted() {
6276 let _lock = env_lock_acquire();
6277 let temp_home = TempHome::new();
6278 temp_home.set_config_json(
6279 r#"{
6280 "provider": "openai",
6281 "providers": {
6282 "openai": {
6283 "api_key": "sk-test",
6284 "model": "gpt-4o"
6285 }
6286 }
6287}"#,
6288 );
6289
6290 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6291 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6292
6293 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6294
6295 assert_eq!(
6296 config
6297 .providers
6298 .openai
6299 .as_ref()
6300 .and_then(|c| c.model.as_deref()),
6301 Some("gpt-4o"),
6302 "config should load provider model from config file even when proxy fields are omitted"
6303 );
6304 assert!(config.http_proxy.is_empty());
6305 assert!(config.https_proxy.is_empty());
6306 }
6307
6308 #[test]
6309 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6310 let _lock = crate::test_support::env_cache_lock_acquire();
6311 let mut config = Config::default();
6312 config.env_vars.extend([
6313 EnvVarEntry {
6314 name: "SECRET_TOKEN".to_string(),
6315 value: "top-secret".to_string(),
6316 secret: true,
6317 value_encrypted: None,
6318 credential_ref: None,
6319 configured: true,
6320 description: Some("Service token".to_string()),
6321 },
6322 EnvVarEntry {
6323 name: "API_BASE".to_string(),
6324 value: "https://internal.example".to_string(),
6325 secret: false,
6326 value_encrypted: None,
6327 credential_ref: None,
6328 configured: true,
6329 description: Some("Internal API base".to_string()),
6330 },
6331 ]);
6332
6333 config.publish_env_vars();
6334
6335 let injected = Config::current_env_vars();
6336 assert_eq!(
6337 injected.get("SECRET_TOKEN").map(String::as_str),
6338 Some("top-secret")
6339 );
6340 assert_eq!(
6341 injected.get("API_BASE").map(String::as_str),
6342 Some("https://internal.example")
6343 );
6344
6345 let prompt_safe = Config::current_prompt_safe_env_vars();
6346 assert_eq!(prompt_safe.len(), 2);
6347 assert!(prompt_safe.iter().any(|entry| {
6348 entry.name == "SECRET_TOKEN"
6349 && entry.secret
6350 && entry.description.as_deref() == Some("Service token")
6351 }));
6352 assert!(prompt_safe.iter().any(|entry| {
6353 entry.name == "API_BASE"
6354 && !entry.secret
6355 && entry.description.as_deref() == Some("Internal API base")
6356 }));
6357 assert!(!prompt_safe
6358 .iter()
6359 .any(|entry| entry.name.contains("top-secret")));
6360 assert!(!prompt_safe.iter().any(|entry| {
6361 entry
6362 .description
6363 .as_deref()
6364 .is_some_and(|value| value.contains("https://internal.example"))
6365 }));
6366 }
6367
6368 #[test]
6369 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6370 let _lock = crate::test_support::env_cache_lock_acquire();
6371
6372 let mut live = Config::default();
6374 live.env_vars.extend([EnvVarEntry {
6375 name: "BAMBOO_CACHE_OWNER_40".to_string(),
6376 value: "live".to_string(),
6377 secret: false,
6378 value_encrypted: None,
6379 credential_ref: None,
6380 configured: true,
6381 description: None,
6382 }]);
6383 live.publish_env_vars();
6384 assert_eq!(
6385 Config::current_env_vars()
6386 .get("BAMBOO_CACHE_OWNER_40")
6387 .map(String::as_str),
6388 Some("live")
6389 );
6390
6391 let temp = TempHome::new();
6393 temp.set_config_json(
6394 &serde_json::json!({
6395 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6396 })
6397 .to_string(),
6398 );
6399
6400 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6403 assert_eq!(
6404 loaded
6405 .env_vars
6406 .iter()
6407 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6408 .map(|e| e.value.as_str()),
6409 Some("stale-disk"),
6410 "the returned Config holds the disk value"
6411 );
6412 assert_eq!(
6413 Config::current_env_vars()
6414 .get("BAMBOO_CACHE_OWNER_40")
6415 .map(String::as_str),
6416 Some("live"),
6417 "but the global cache is UNTOUCHED — no clobber (#40)"
6418 );
6419
6420 let _ = Config::from_data_dir(Some(temp.path.clone()));
6422 assert_eq!(
6423 Config::current_env_vars()
6424 .get("BAMBOO_CACHE_OWNER_40")
6425 .map(String::as_str),
6426 Some("stale-disk"),
6427 "the publishing loader clobbers the cache (contrast)"
6428 );
6429 }
6430
6431 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6432 std::fs::read_dir(dir)
6433 .unwrap()
6434 .filter_map(|e| e.ok())
6435 .any(|e| {
6436 e.file_name()
6437 .to_string_lossy()
6438 .contains("config.json.corrupted.")
6439 })
6440 }
6441
6442 #[test]
6443 fn corrupt_config_recovered_from_backup_and_quarantined() {
6444 let temp = TempHome::new();
6445 std::fs::write(
6447 temp.path.join("config.json.bak"),
6448 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6449 )
6450 .unwrap();
6451 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6453
6454 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6455 assert_eq!(
6456 config.http_proxy, "http://from-backup",
6457 "recovered from config.json.bak instead of losing all config"
6458 );
6459 assert!(
6460 dir_has_quarantine_file(&temp.path),
6461 "corrupt config.json was quarantined (preserved), not discarded"
6462 );
6463 }
6464
6465 #[test]
6466 fn corrupt_config_without_backup_quarantines_then_defaults() {
6467 let temp = TempHome::new();
6468 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6469
6470 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6471 assert!(
6472 config.http_proxy.is_empty(),
6473 "no backup -> falls back to defaults"
6474 );
6475 assert!(
6476 dir_has_quarantine_file(&temp.path),
6477 "corrupt config.json is quarantined even when there's no backup"
6478 );
6479 }
6480
6481 #[test]
6482 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6483 let temp = TempHome::new();
6484 temp.set_config_json(
6489 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6490 );
6491
6492 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6493 assert_eq!(
6494 config.http_proxy, "http://salvaged",
6495 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
6496 );
6497 assert!(
6498 config.env_vars.is_empty(),
6499 "the corrupt field reset to its default instead of failing the whole load"
6500 );
6501 assert!(
6502 dir_has_quarantine_file(&temp.path),
6503 "the corrupt config.json was still quarantined for inspection"
6504 );
6505 }
6506
6507 #[test]
6508 fn salvage_preferred_over_backup_for_most_recent_intent() {
6509 let temp = TempHome::new();
6510 std::fs::write(
6512 temp.path.join("config.json.bak"),
6513 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
6514 )
6515 .unwrap();
6516 temp.set_config_json(
6518 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
6519 );
6520
6521 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6522 assert_eq!(
6523 config.http_proxy, "http://new-salvaged",
6524 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
6525 );
6526 }
6527
6528 #[test]
6529 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
6530 let temp = TempHome::new();
6531 std::fs::write(
6533 temp.path.join("config.json.bak"),
6534 serde_json::json!({
6535 "http_proxy": "http://old-from-backup",
6536 "https_proxy": "https://kept-from-backup",
6537 })
6538 .to_string(),
6539 )
6540 .unwrap();
6541 temp.set_config_json(
6544 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
6545 );
6546
6547 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6548 assert_eq!(
6550 config.http_proxy, "http://newer-edit",
6551 "the corrupt file's newer valid edit is applied"
6552 );
6553 assert_eq!(
6555 config.https_proxy, "https://kept-from-backup",
6556 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
6557 );
6558 }
6559
6560 #[test]
6561 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
6562 let temp = TempHome::new();
6563 std::fs::write(
6564 temp.path.join("config.json.bak"),
6565 serde_json::json!({
6566 "providers": {
6567 "anthropic": { "model": "claude-backup" }
6568 },
6569 "memory": {
6570 "auto_dream_enabled": true
6571 },
6572 "subagents": {
6573 "claude_code_model": "claude-code-backup"
6574 }
6575 })
6576 .to_string(),
6577 )
6578 .unwrap();
6579 assert!(!temp.path.join("providers.json").exists());
6580 assert!(!temp.path.join("memory.json").exists());
6581 assert!(!temp.path.join("subagents.json").exists());
6582
6583 let (salvaged, recovered_fields) = Config::salvage_partial(
6584 r#"{"providers":"schema-invalid-provider-section"}"#,
6585 &temp.path,
6586 )
6587 .expect("object-shaped corrupt config should be salvageable");
6588
6589 assert!(
6590 recovered_fields.is_empty(),
6591 "the schema-invalid provider field must not replace the backup baseline"
6592 );
6593 assert_eq!(
6594 salvaged
6595 .providers()
6596 .anthropic
6597 .as_ref()
6598 .and_then(|provider| provider.model.as_deref()),
6599 Some("claude-backup")
6600 );
6601 assert!(
6602 salvaged
6603 .memory()
6604 .as_ref()
6605 .expect("backup memory config survives")
6606 .auto_dream_enabled
6607 );
6608 assert_eq!(
6609 salvaged.subagents().claude_code_model.as_deref(),
6610 Some("claude-code-backup")
6611 );
6612 }
6613
6614 #[test]
6615 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
6616 let temp = TempHome::new();
6617 std::fs::write(
6620 temp.path.join("config.json.bak"),
6621 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6622 )
6623 .unwrap();
6624 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6625
6626 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6627 assert_eq!(
6628 config.http_proxy, "http://from-backup",
6629 "garbage (non-object) config skips salvage and recovers from .bak"
6630 );
6631 }
6632
6633 #[test]
6634 fn quarantine_files_are_capped_to_newest_n() {
6635 let temp = TempHome::new();
6636 let config_path = temp.path.join("config.json");
6637 std::fs::write(&config_path, "{}").unwrap();
6638
6639 for _ in 0..(QUARANTINE_KEEP + 3) {
6642 quarantine_corrupt_config(&config_path);
6643 std::thread::sleep(std::time::Duration::from_millis(3));
6644 }
6645
6646 let count = std::fs::read_dir(&temp.path)
6647 .unwrap()
6648 .filter_map(|e| e.ok())
6649 .filter(|e| {
6650 e.file_name()
6651 .to_string_lossy()
6652 .starts_with("config.json.corrupted.")
6653 })
6654 .count();
6655 assert_eq!(
6656 count, QUARANTINE_KEEP,
6657 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
6658 );
6659 }
6660
6661 #[test]
6662 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
6663 let temp = TempHome::new();
6664 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
6666 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
6667 std::fs::write(
6668 temp.path.join("config.json.bak.1"),
6669 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
6670 )
6671 .unwrap();
6672
6673 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6674 assert_eq!(
6675 config.http_proxy, "http://from-gen-1",
6676 "recovered from .bak.1 when both config.json and .bak are corrupt"
6677 );
6678 }
6679
6680 #[test]
6681 fn save_rotates_backup_generations() {
6682 let temp = TempHome::new();
6683 let path = temp.path.join("config.json");
6684 std::fs::write(
6686 &path,
6687 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
6688 )
6689 .unwrap();
6690
6691 let mut cfg = Config::create_default();
6692 cfg.http_proxy = "http://proxy-v2".to_string();
6694 cfg.save_to_dir(temp.path.clone()).unwrap();
6695 cfg.http_proxy = "http://proxy-v3".to_string();
6697 cfg.save_to_dir(temp.path.clone()).unwrap();
6698
6699 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6700 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
6701 assert!(
6702 bak.contains("proxy-v2"),
6703 ".bak holds the previous generation (v2)"
6704 );
6705 assert!(
6706 bak1.contains("proxy-v1"),
6707 ".bak.1 holds the older rotated generation (v1)"
6708 );
6709 }
6710
6711 #[test]
6712 fn save_backs_up_existing_config() {
6713 let temp = TempHome::new();
6714 std::fs::write(
6716 temp.path.join("config.json"),
6717 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
6718 )
6719 .unwrap();
6720
6721 let mut config = Config::create_default();
6722 config.http_proxy = "http://new".to_string();
6723 config
6724 .save_to_dir(temp.path.clone())
6725 .expect("save succeeds");
6726
6727 let backup =
6728 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
6729 assert!(
6730 backup.contains("http://old"),
6731 "config.json.bak holds the PREVIOUS config (last-known-good)"
6732 );
6733 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
6734 assert!(
6735 current.contains("http://new"),
6736 "config.json holds the new config"
6737 );
6738 }
6739
6740 #[test]
6741 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
6742 let temp = TempHome::new();
6743 std::fs::write(
6745 temp.path.join("config.json.bak"),
6746 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
6747 )
6748 .unwrap();
6749 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
6752
6753 let mut config = Config::create_default();
6754 config.http_proxy = "http://new".to_string();
6755 config
6756 .save_to_dir(temp.path.clone())
6757 .expect("save succeeds");
6758
6759 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6761 assert!(
6762 backup.contains("http://good-bak"),
6763 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
6764 );
6765 }
6766
6767 #[test]
6770 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
6771 let temp = TempHome::new();
6772 std::fs::write(
6773 temp.path.join("config.json.bak"),
6774 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6775 )
6776 .unwrap();
6777 let corrupt_bytes = "{ not valid json ";
6778 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
6779
6780 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6781 let status = config
6782 .recovery_status()
6783 .expect("a corrupt load must set a pending recovery status");
6784 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
6785 assert_eq!(
6786 status.source,
6787 ConfigRecoverySource::Backup { generation: 0 },
6788 "recovered from generation-0 (.bak)"
6789 );
6790 let quarantine_path = status
6791 .quarantine_path
6792 .as_ref()
6793 .expect("quarantine copy should have succeeded");
6794 assert_eq!(
6795 std::fs::read_to_string(quarantine_path).unwrap(),
6796 corrupt_bytes,
6797 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
6798 );
6799 assert_eq!(
6800 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6801 corrupt_bytes,
6802 "the original config.json itself is untouched by the load (only copied, not moved)"
6803 );
6804 }
6805
6806 #[test]
6807 fn recovery_status_set_from_salvage_lists_recovered_fields() {
6808 let temp = TempHome::new();
6809 temp.set_config_json(
6810 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6811 );
6812
6813 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6814 let status = config.recovery_status().expect("pending recovery");
6815 assert!(!status.confirmed);
6816 match &status.source {
6817 ConfigRecoverySource::Salvaged { fields } => {
6818 assert!(
6819 fields.iter().any(|f| f == "http_proxy"),
6820 "salvaged fields should list the recovered key: {fields:?}"
6821 );
6822 }
6823 other => panic!("expected Salvaged source, got {other:?}"),
6824 }
6825 }
6826
6827 #[test]
6828 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
6829 let temp = TempHome::new();
6830 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6832
6833 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6834 let status = config.recovery_status().expect("pending recovery");
6835 assert!(!status.confirmed);
6836 assert_eq!(status.source, ConfigRecoverySource::Defaults);
6837 }
6838
6839 #[test]
6840 fn clean_load_never_sets_recovery_status() {
6841 let temp = TempHome::new();
6842 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
6843
6844 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6845 assert!(
6846 config.recovery_status().is_none(),
6847 "a config.json that parses cleanly must never carry a pending recovery status"
6848 );
6849 }
6850
6851 #[test]
6852 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
6853 let temp = TempHome::new();
6854 let corrupt_bytes = "}}} broken";
6855 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
6856
6857 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6858 assert!(config.recovery_status().is_some());
6859
6860 let err = config
6861 .save_to_dir(temp.path.clone())
6862 .expect_err("save must refuse while recovery is unconfirmed");
6863 assert!(
6864 err.to_string().contains("recovered from corruption")
6865 || err.to_string().contains("confirm"),
6866 "error should explain the refused overwrite: {err}"
6867 );
6868
6869 assert_eq!(
6872 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6873 corrupt_bytes,
6874 "a refused save must leave the corrupt original untouched"
6875 );
6876 }
6877
6878 #[test]
6879 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
6880 let temp = TempHome::new();
6881 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
6883 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
6884
6885 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6886 let status = config.recovery_status().expect("pending recovery");
6887 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
6888 assert_eq!(
6889 std::fs::read_to_string(quarantine_path).unwrap(),
6890 truncated,
6891 "truncated original preserved byte for byte in quarantine"
6892 );
6893
6894 let err = config.save_to_dir(temp.path.clone());
6895 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
6896 assert_eq!(
6897 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
6898 truncated,
6899 "the half-written original stays exactly as it was after a refused save"
6900 );
6901 }
6902
6903 #[test]
6904 fn confirm_recovery_allows_the_next_save() {
6905 let temp = TempHome::new();
6906 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6907
6908 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6909 assert!(config.recovery_status().is_some());
6910
6911 config.confirm_recovery();
6912 assert!(
6913 config.recovery_status().is_some_and(|s| s.confirmed),
6914 "confirm_recovery flips the flag but keeps the status around"
6915 );
6916
6917 config
6918 .save_to_dir(temp.path.clone())
6919 .expect("save must succeed once the recovery is confirmed");
6920 }
6921
6922 #[test]
6923 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
6924 let temp = TempHome::new();
6925 std::fs::write(
6926 temp.path.join("config.json"),
6927 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
6928 )
6929 .unwrap();
6930
6931 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6932 assert!(config.recovery_status().is_some());
6933 let quarantine_path = config
6934 .recovery_status()
6935 .unwrap()
6936 .quarantine_path
6937 .clone()
6938 .unwrap();
6939
6940 config
6941 .confirm_recovery_and_save_to_dir(temp.path.clone())
6942 .expect("confirm+save should succeed");
6943
6944 assert!(
6945 config.recovery_status().is_none(),
6946 "the pending flag is cleared once the recovery is confirmed and persisted"
6947 );
6948 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
6949 assert!(
6950 on_disk.contains("http://recovered"),
6951 "config.json now holds the recovered (salvaged) state"
6952 );
6953 assert!(
6956 quarantine_path.exists(),
6957 "the quarantined original survives confirmation — it's never deleted"
6958 );
6959 }
6960
6961 #[test]
6962 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
6963 let temp = TempHome::new();
6964 let mut config = Config::create_default();
6965 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
6966 assert!(
6967 err.is_err(),
6968 "confirming a recovery that was never pending must error, not silently succeed"
6969 );
6970 }
6971
6972 fn connect_platform_with_encrypted(
6975 platform_type: &str,
6976 token_encrypted: &str,
6977 ) -> ConnectPlatformConfig {
6978 ConnectPlatformConfig {
6979 id: None,
6980 project_id: None,
6981 platform_type: platform_type.to_string(),
6982 token: None,
6983 token_encrypted: Some(token_encrypted.to_string()),
6984 token_credential_ref: None,
6985 token_configured: false,
6986 app_id: None,
6987 app_secret: None,
6988 app_secret_encrypted: None,
6989 app_secret_credential_ref: None,
6990 app_secret_configured: false,
6991 domain: None,
6992 allow_from: vec!["user-1".to_string()],
6993 admin_from: Vec::new(),
6994 }
6995 }
6996
6997 fn connect_json_path(temp: &TempHome) -> PathBuf {
6998 temp.path.join("connect.json")
6999 }
7000
7001 #[test]
7002 fn save_splits_connect_into_sibling_connect_json() {
7003 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7004 let temp = TempHome::new();
7005
7006 let mut config = Config::create_default();
7007 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7008 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7009
7010 config
7011 .save_to_dir(temp.path.clone())
7012 .expect("save succeeds");
7013
7014 let config_json: serde_json::Value =
7015 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7016 .unwrap();
7017 assert!(
7018 config_json.get("connect").is_none(),
7019 "config.json must not carry the `connect` key after a save"
7020 );
7021
7022 let connect_json: serde_json::Value =
7023 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7024 .unwrap();
7025 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7026 assert!(
7027 connect_json["platforms"][0]["token_encrypted"]
7028 .as_str()
7029 .is_some_and(|v| !v.is_empty()),
7030 "the token is persisted in its encrypted form in connect.json"
7031 );
7032 assert!(
7033 connect_json["platforms"][0].get("token").is_none(),
7034 "the plaintext token is never persisted (skip_serializing)"
7035 );
7036 }
7037
7038 #[test]
7041 fn save_assigns_a_missing_connect_platform_id() {
7042 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7043 let temp = TempHome::new();
7044
7045 let mut config = Config::create_default();
7046 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7047 assert!(
7048 config.connect.platforms[0].id.is_none(),
7049 "precondition: the entry starts without an id"
7050 );
7051
7052 config
7053 .save_to_dir(temp.path.clone())
7054 .expect("save succeeds");
7055
7056 let connect_json: serde_json::Value =
7057 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7058 .unwrap();
7059 let persisted_id = connect_json["platforms"][0]["id"]
7060 .as_str()
7061 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7062 assert!(!persisted_id.is_empty());
7063
7064 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7065 assert_eq!(
7066 reloaded.connect.platforms[0].id.as_deref(),
7067 Some(persisted_id),
7068 "the assigned id round-trips through a reload"
7069 );
7070 }
7071
7072 #[test]
7073 fn save_never_reassigns_an_existing_connect_platform_id() {
7074 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7075 let temp = TempHome::new();
7076
7077 let mut config = Config::create_default();
7078 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7079 platform.id = Some("stable-id-123".to_string());
7080 config.connect.platforms = vec![platform];
7081
7082 config
7083 .save_to_dir(temp.path.clone())
7084 .expect("first save succeeds");
7085 config
7087 .save_to_dir(temp.path.clone())
7088 .expect("second save succeeds");
7089
7090 let connect_json: serde_json::Value =
7091 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7092 .unwrap();
7093 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7094 }
7095
7096 #[test]
7097 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7098 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7099 let temp = TempHome::new();
7100
7101 let mut config = Config::create_default();
7102 config.connect.platforms = vec![
7103 connect_platform_with_encrypted("telegram", "cipher-a"),
7104 connect_platform_with_encrypted("telegram", "cipher-b"),
7105 ];
7106
7107 config
7108 .save_to_dir(temp.path.clone())
7109 .expect("save succeeds");
7110
7111 let connect_json: serde_json::Value =
7112 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7113 .unwrap();
7114 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7115 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7116 assert_ne!(
7117 id_a, id_b,
7118 "two entries sharing platform_type must still get distinct ids"
7119 );
7120 }
7121
7122 #[test]
7123 fn load_never_assigns_or_persists_an_id_by_itself() {
7124 let temp = TempHome::new();
7125 std::fs::write(
7126 connect_json_path(&temp),
7127 serde_json::json!({
7128 "platforms": [
7129 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7130 ]
7131 })
7132 .to_string(),
7133 )
7134 .unwrap();
7135 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7136
7137 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7138
7139 assert!(
7140 config.connect.platforms[0].id.is_none(),
7141 "load alone must not backfill an id in memory"
7142 );
7143 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7144 assert_eq!(
7145 connect_json_before, connect_json_after,
7146 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7147 );
7148 }
7149
7150 #[test]
7151 fn load_merges_connect_json_into_config() {
7152 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7153 let temp = TempHome::new();
7154 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7155 std::fs::write(
7156 connect_json_path(&temp),
7157 serde_json::json!({
7158 "platforms": [
7159 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7160 ]
7161 })
7162 .to_string(),
7163 )
7164 .unwrap();
7165
7166 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7167 assert_eq!(config.connect.platforms.len(), 1);
7168 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7169 assert_eq!(
7170 config.connect.platforms[0].token.as_deref(),
7171 Some("connect-secret")
7172 );
7173 assert!(config.connect.platforms[0].token_encrypted.is_none());
7174 }
7175
7176 #[test]
7177 fn load_without_connect_json_yields_empty_inert_connect_config() {
7178 let temp = TempHome::new();
7179 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7180
7181 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7182 assert!(
7183 config.connect.platforms.is_empty(),
7184 "no connect.json and no legacy key -> empty/inert connect config"
7185 );
7186 assert!(
7187 !connect_json_path(&temp).exists(),
7188 "load must not create connect.json when there is nothing to migrate"
7189 );
7190 }
7191
7192 #[test]
7193 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7194 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7195 let temp = TempHome::new();
7196 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7197 let legacy_cipher_for_assert = legacy_cipher.clone();
7198 temp.set_config_json(
7200 &serde_json::json!({
7201 "http_proxy": "http://keep-me",
7202 "connect": {
7203 "platforms": [
7204 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7205 ]
7206 }
7207 })
7208 .to_string(),
7209 );
7210
7211 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7212
7213 assert_eq!(config.connect.platforms.len(), 1);
7215 assert_eq!(
7216 config.connect.platforms[0].token.as_deref(),
7217 Some("legacy-connect-secret")
7218 );
7219 assert_eq!(config.http_proxy, "http://keep-me");
7221
7222 assert!(
7224 connect_json_path(&temp).exists(),
7225 "migration proactively creates connect.json"
7226 );
7227 let connect_json: serde_json::Value =
7228 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7229 .unwrap();
7230 assert_eq!(
7231 connect_json["platforms"][0]["token_encrypted"],
7232 legacy_cipher_for_assert
7233 );
7234
7235 let config_json: serde_json::Value =
7237 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7238 .unwrap();
7239 assert!(
7240 config_json.get("connect").is_none(),
7241 "config.json is rewritten without the legacy `connect` key"
7242 );
7243 }
7244
7245 #[test]
7253 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7254 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7255 let temp = TempHome::new();
7256
7257 let original_api_key_encrypted =
7258 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7259 temp.set_config_json(
7260 &serde_json::json!({
7261 "providers": {
7262 "openai": {
7263 "api_key_encrypted": original_api_key_encrypted,
7264 }
7265 },
7266 "connect": {
7267 "platforms": [
7268 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7269 ]
7270 }
7271 })
7272 .to_string(),
7273 );
7274
7275 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7276 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7277
7278 assert!(
7281 !temp.path.join("config.json.bak").exists(),
7282 "a read-only load migrating a legacy `connect` key must not rotate \
7283 config.json backups"
7284 );
7285
7286 let config_json: serde_json::Value =
7291 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7292 .unwrap();
7293 assert_eq!(
7294 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7295 "an unrelated secret's ciphertext must not be touched by the connect \
7296 migration's narrow write"
7297 );
7298 assert!(
7299 config_json.get("connect").is_none(),
7300 "config.json is still rewritten without the legacy `connect` key"
7301 );
7302 }
7303
7304 #[test]
7305 fn both_files_present_connect_json_wins() {
7306 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7307 let temp = TempHome::new();
7308 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7309 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7310 temp.set_config_json(
7311 &serde_json::json!({
7312 "connect": {
7313 "platforms": [
7314 { "type": "telegram", "token_encrypted": stale }
7315 ]
7316 }
7317 })
7318 .to_string(),
7319 );
7320 std::fs::write(
7321 connect_json_path(&temp),
7322 serde_json::json!({
7323 "platforms": [
7324 { "type": "telegram", "token_encrypted": authoritative }
7325 ]
7326 })
7327 .to_string(),
7328 )
7329 .unwrap();
7330
7331 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7332 assert_eq!(
7333 config.connect.platforms[0].token.as_deref(),
7334 Some("authoritative-secret"),
7335 "connect.json wins over a stale legacy config.json key"
7336 );
7337 }
7338
7339 #[test]
7344 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7345 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7346 let temp = TempHome::new();
7347 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7348 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7349 temp.set_config_json(
7350 &serde_json::json!({
7351 "http_proxy": "http://keep-me",
7352 "connect": {
7353 "platforms": [
7354 { "type": "telegram", "token_encrypted": stale }
7355 ]
7356 }
7357 })
7358 .to_string(),
7359 );
7360 std::fs::write(
7361 connect_json_path(&temp),
7362 serde_json::json!({
7363 "platforms": [
7364 { "type": "telegram", "token_encrypted": authoritative }
7365 ]
7366 })
7367 .to_string(),
7368 )
7369 .unwrap();
7370
7371 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7372 assert_eq!(
7373 config.connect.platforms[0].token.as_deref(),
7374 Some("authoritative-secret")
7375 );
7376 assert_eq!(config.http_proxy, "http://keep-me");
7378
7379 let config_json: serde_json::Value =
7380 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7381 .unwrap();
7382 assert!(
7383 config_json.get("connect").is_none(),
7384 "the stale legacy `connect` key must be stripped from config.json \
7385 immediately on load, not left for the next natural save"
7386 );
7387 }
7388
7389 #[test]
7397 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7398 let temp = TempHome::new();
7399 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7400 std::fs::write(
7401 temp.path.join("config.json.bak"),
7402 serde_json::json!({
7403 "http_proxy": "http://old",
7404 "connect": {
7405 "platforms": [
7406 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7407 ]
7408 }
7409 })
7410 .to_string(),
7411 )
7412 .unwrap();
7413
7414 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7415 assert!(config.connect.platforms.is_empty());
7418
7419 let bak: serde_json::Value = serde_json::from_str(
7420 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7421 )
7422 .unwrap();
7423 assert!(
7424 bak.get("connect").is_none(),
7425 "the legacy `connect` key must be stripped from the tainted .bak generation"
7426 );
7427 assert_eq!(
7428 bak["http_proxy"], "http://old",
7429 "every other key in the .bak generation survives the scrub byte-for-byte in content"
7430 );
7431 }
7432
7433 #[test]
7438 fn scrub_reaches_all_rotated_generations() {
7439 let temp = TempHome::new();
7440 temp.set_config_json(&serde_json::json!({}).to_string());
7441 for (gen_suffix, cipher) in [
7442 ("config.json.bak", "cipher-gen0"),
7443 ("config.json.bak.1", "cipher-gen1"),
7444 ("config.json.bak.2", "cipher-gen2"),
7445 ] {
7446 std::fs::write(
7447 temp.path.join(gen_suffix),
7448 serde_json::json!({
7449 "connect": {
7450 "platforms": [
7451 { "type": "telegram", "token_encrypted": cipher }
7452 ]
7453 }
7454 })
7455 .to_string(),
7456 )
7457 .unwrap();
7458 }
7459
7460 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7461
7462 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7463 let value: serde_json::Value =
7464 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7465 .unwrap();
7466 assert!(
7467 value.get("connect").is_none(),
7468 "{gen_suffix} must have its legacy `connect` key stripped"
7469 );
7470 }
7471 }
7472
7473 #[test]
7481 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7482 let temp = TempHome::new();
7483 temp.set_config_json(&serde_json::json!({}).to_string());
7484 let bak_path = temp.path.join("config.json.bak");
7485 std::fs::write(
7486 &bak_path,
7487 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7488 )
7489 .unwrap();
7490
7491 let before_bytes = std::fs::read(&bak_path).unwrap();
7492 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7493
7494 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7498
7499 let after_bytes = std::fs::read(&bak_path).unwrap();
7500 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7501 assert_eq!(
7502 before_bytes, after_bytes,
7503 "a .bak generation without a legacy `connect` key must not be rewritten at all"
7504 );
7505 assert_eq!(
7506 before_mtime, after_mtime,
7507 "no write means no mtime change either"
7508 );
7509 }
7510
7511 #[test]
7516 fn scrub_skips_unparsable_backup_without_deleting_it() {
7517 let temp = TempHome::new();
7518 temp.set_config_json(&serde_json::json!({}).to_string());
7519 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
7520
7521 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7522
7523 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7524 assert_eq!(
7525 content, "{ not valid json",
7526 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
7527 );
7528 }
7529
7530 #[test]
7534 fn scrub_tolerates_missing_generations() {
7535 let temp = TempHome::new();
7536 temp.set_config_json(&serde_json::json!({}).to_string());
7537 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7539 assert!(config.connect.platforms.is_empty());
7540 assert!(!temp.path.join("config.json.bak").exists());
7541 }
7542
7543 #[test]
7546 fn scrub_does_not_break_backup_rotation() {
7547 let temp = TempHome::new();
7548 std::fs::write(
7549 temp.path.join("config.json"),
7550 serde_json::json!({
7551 "http_proxy": "http://proxy-v1",
7552 "connect": {
7553 "platforms": [
7554 { "type": "telegram", "token_encrypted": "legacy-cipher" }
7555 ]
7556 }
7557 })
7558 .to_string(),
7559 )
7560 .unwrap();
7561 std::fs::write(
7562 temp.path.join("config.json.bak"),
7563 serde_json::json!({
7564 "http_proxy": "http://proxy-v0",
7565 "connect": {
7566 "platforms": [
7567 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
7568 ]
7569 }
7570 })
7571 .to_string(),
7572 )
7573 .unwrap();
7574
7575 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7577 let bak: serde_json::Value = serde_json::from_str(
7578 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7579 )
7580 .unwrap();
7581 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
7582
7583 config.http_proxy = "http://proxy-v2".to_string();
7586 config.save_to_dir(temp.path.clone()).unwrap();
7587
7588 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7589 assert!(
7590 new_bak.contains("proxy-v1"),
7591 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
7592 );
7593 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7594 assert!(
7595 new_bak1.contains("proxy-v0"),
7596 ".bak.1 holds the scrubbed older generation after rotation"
7597 );
7598 assert!(
7599 !new_bak1.contains("legacy-bak-cipher"),
7600 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
7601 stripped secret"
7602 );
7603 }
7604
7605 #[test]
7606 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
7607 let temp = TempHome::new();
7608 temp.set_config_json("{}");
7609 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7610
7611 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7612 assert!(
7613 config.connect.platforms.is_empty(),
7614 "corrupt connect.json fails SAFE to an empty/inert connect config"
7615 );
7616
7617 let backup = connect_json_path(&temp).with_extension("json.bak");
7618 assert!(
7619 backup.exists(),
7620 "the corrupt connect.json is quarantined to connect.json.bak"
7621 );
7622 assert!(
7623 std::fs::read_to_string(backup)
7624 .unwrap()
7625 .contains("not valid json"),
7626 "the quarantined copy holds the bad content"
7627 );
7628 assert!(
7633 !connect_json_path(&temp).exists(),
7634 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
7635 connect.json should remain after quarantine"
7636 );
7637 }
7638
7639 #[test]
7640 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
7641 let temp = TempHome::new();
7642 temp.set_config_json(
7645 &serde_json::json!({
7646 "connect": {
7647 "platforms": [
7648 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
7649 ]
7650 }
7651 })
7652 .to_string(),
7653 );
7654 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7655
7656 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7657 assert!(
7658 config.connect.platforms.is_empty(),
7659 "corrupt connect.json must not fall back to the legacy config.json copy"
7660 );
7661 }
7662
7663 #[test]
7664 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
7665 let temp = TempHome::new();
7666 let config = Config::create_default();
7667 assert!(config.connect.platforms.is_empty());
7668
7669 config
7670 .save_to_dir(temp.path.clone())
7671 .expect("save succeeds");
7672
7673 assert!(
7674 !connect_json_path(&temp).exists(),
7675 "an empty connect config with no pre-existing file must not create one"
7676 );
7677 }
7678
7679 #[test]
7680 fn connect_json_backed_up_before_overwrite() {
7681 let temp = TempHome::new();
7682 std::fs::write(
7683 connect_json_path(&temp),
7684 serde_json::json!({
7685 "platforms": [
7686 { "type": "telegram", "token_encrypted": "old-cipher" }
7687 ]
7688 })
7689 .to_string(),
7690 )
7691 .unwrap();
7692
7693 let mut config = Config::create_default();
7694 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
7695 config
7696 .save_to_dir(temp.path.clone())
7697 .expect("save succeeds");
7698
7699 let backup = connect_json_path(&temp).with_extension("json.bak");
7700 assert!(
7701 std::fs::read_to_string(backup)
7702 .unwrap()
7703 .contains("old-cipher"),
7704 "the previous connect.json is preserved as connect.json.bak before the overwrite"
7705 );
7706 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7707 assert!(current.contains("new-cipher"));
7708 }
7709
7710 #[test]
7713 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
7714 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7715 let temp = TempHome::new();
7716
7717 let mut config = Config::create_default();
7718 config.connect.platforms = vec![ConnectPlatformConfig {
7719 id: None,
7720 project_id: None,
7721 platform_type: "feishu".to_string(),
7722 token: None,
7723 token_encrypted: None,
7724 token_credential_ref: None,
7725 token_configured: false,
7726 app_id: Some("cli_real_app_id".to_string()),
7727 app_secret: Some("plain-app-secret".to_string()),
7728 app_secret_encrypted: None,
7729 app_secret_credential_ref: None,
7730 app_secret_configured: false,
7731 domain: Some("lark".to_string()),
7732 allow_from: vec!["ou_1".to_string()],
7733 admin_from: Vec::new(),
7734 }];
7735
7736 config
7737 .save_to_dir(temp.path.clone())
7738 .expect("save succeeds");
7739
7740 let connect_json: serde_json::Value =
7741 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7742 .unwrap();
7743 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
7744 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
7745 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
7746 assert!(
7747 connect_json["platforms"][0]["app_secret_encrypted"]
7748 .as_str()
7749 .is_some_and(|v| !v.is_empty()),
7750 "app_secret is persisted in its encrypted form in connect.json"
7751 );
7752 assert!(
7753 connect_json["platforms"][0].get("app_secret").is_none(),
7754 "the plaintext app_secret is never persisted (skip_serializing)"
7755 );
7756 }
7757
7758 #[test]
7759 fn load_hydrates_feishu_app_secret_from_encrypted() {
7760 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7761 let temp = TempHome::new();
7762
7763 let mut config = Config::create_default();
7764 config.connect.platforms = vec![ConnectPlatformConfig {
7765 id: None,
7766 project_id: None,
7767 platform_type: "feishu".to_string(),
7768 token: None,
7769 token_encrypted: None,
7770 token_credential_ref: None,
7771 token_configured: false,
7772 app_id: Some("cli_real_app_id".to_string()),
7773 app_secret: Some("plain-app-secret".to_string()),
7774 app_secret_encrypted: None,
7775 app_secret_credential_ref: None,
7776 app_secret_configured: false,
7777 domain: Some("lark".to_string()),
7778 allow_from: vec!["ou_1".to_string()],
7779 admin_from: Vec::new(),
7780 }];
7781 config
7782 .save_to_dir(temp.path.clone())
7783 .expect("save succeeds");
7784
7785 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7786 assert_eq!(reloaded.connect.platforms.len(), 1);
7787 assert_eq!(
7788 reloaded.connect.platforms[0].app_secret.as_deref(),
7789 Some("plain-app-secret"),
7790 "reload hydrates app_secret from app_secret_encrypted"
7791 );
7792 assert_eq!(
7793 reloaded.connect.platforms[0].app_id.as_deref(),
7794 Some("cli_real_app_id")
7795 );
7796 assert_eq!(
7797 reloaded.connect.platforms[0].domain.as_deref(),
7798 Some("lark")
7799 );
7800 }
7801
7802 #[test]
7803 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
7804 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
7805 let temp = TempHome::new();
7806 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
7807 std::fs::write(
7808 connect_json_path(&temp),
7809 serde_json::json!({
7810 "platforms": [
7811 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7812 ]
7813 })
7814 .to_string(),
7815 )
7816 .unwrap();
7817
7818 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7819
7820 assert_eq!(config.connect.platforms.len(), 1);
7821 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7822 assert_eq!(
7823 config.connect.platforms[0].token.as_deref(),
7824 Some("legacy-telegram-secret")
7825 );
7826 assert_eq!(
7827 config.connect.platforms[0].app_id, None,
7828 "a legacy entry with no Feishu fields deserializes them as None"
7829 );
7830 assert_eq!(config.connect.platforms[0].app_secret, None);
7831 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
7832 assert_eq!(config.connect.platforms[0].domain, None);
7833 }
7834
7835 #[test]
7836 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
7837 let _lock = env_lock_acquire();
7838 let temp_home = TempHome::new();
7839 temp_home.set_config_json(
7840 r#"{
7841 "provider": "openai",
7842 "providers": {
7843 "openai": {
7844 "api_key": "sk-test",
7845 "model": "gpt-4o"
7846 }
7847 }
7848}"#,
7849 );
7850
7851 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7852 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7853
7854 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7855
7856 assert_eq!(
7857 config
7858 .providers
7859 .openai
7860 .as_ref()
7861 .and_then(|c| c.model.as_deref()),
7862 Some("gpt-4o")
7863 );
7864 assert!(
7865 config.http_proxy.is_empty(),
7866 "config should keep http_proxy empty when field is omitted"
7867 );
7868 assert!(
7869 config.https_proxy.is_empty(),
7870 "config should keep https_proxy empty when field is omitted"
7871 );
7872 }
7873
7874 #[test]
7875 fn get_memory_background_model_prefers_memory_specific_override() {
7876 let mut config = Config::default();
7877 config.features.provider_model_ref = false;
7878 config.provider = "openai".to_string();
7879 config.providers.openai = Some(OpenAIConfig {
7880 api_key: "test".to_string(),
7881 api_key_encrypted: None,
7882 credential_ref: None,
7883 base_url: None,
7884 model: Some("gpt-main".to_string()),
7885 fast_model: Some("gpt-fast".to_string()),
7886 vision_model: None,
7887 reasoning_effort: None,
7888 responses_only_models: vec![],
7889 request_overrides: None,
7890 extra: BTreeMap::new(),
7891 api_key_from_env: false,
7892 });
7893 config.memory.0 = Some(MemoryConfig {
7894 background_model: Some("memory-fast".to_string()),
7895 ..MemoryConfig::default()
7896 });
7897
7898 assert_eq!(
7899 config.get_memory_background_model().as_deref(),
7900 Some("memory-fast")
7901 );
7902 }
7903
7904 #[test]
7905 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
7906 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
7911 api_key: api_key.to_string(),
7912 api_key_encrypted: None,
7913 credential_ref: None,
7914 base_url: None,
7915 model: None,
7916 fast_model: None,
7917 vision_model: None,
7918 reasoning_effort: None,
7919 responses_only_models: vec![],
7920 request_overrides: None,
7921 extra: BTreeMap::new(),
7922 api_key_from_env: from_env,
7923 };
7924
7925 let mut current = Config::default();
7927 current.providers.openai = Some(openai("sk-env", true));
7928 let mut merged = Config::default();
7929 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
7931 let got = merged.providers.openai.as_ref().unwrap();
7932 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
7933 assert!(got.api_key_from_env, "env flag restored");
7934
7935 let mut merged = Config::default();
7937 merged.providers.openai = Some(openai("sk-explicit", false));
7938 merged.preserve_env_sourced_provider_keys(¤t);
7939 assert_eq!(
7940 merged.providers.openai.as_ref().unwrap().api_key,
7941 "sk-explicit",
7942 "explicit patch key must win"
7943 );
7944
7945 let mut current_plain = Config::default();
7947 current_plain.providers.openai = Some(openai("sk-plain", false));
7948 let mut merged = Config::default();
7949 merged.providers.openai = Some(openai("", false));
7950 merged.preserve_env_sourced_provider_keys(¤t_plain);
7951 assert!(
7952 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
7953 "non-env key must not be restored by this path"
7954 );
7955 }
7956
7957 #[test]
7958 fn refresh_preserves_ciphertext_when_plaintext_empty() {
7959 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
7963 api_key: api_key.to_string(),
7964 api_key_encrypted: enc.map(str::to_string),
7965 credential_ref: None,
7966 base_url: None,
7967 model: None,
7968 fast_model: None,
7969 vision_model: None,
7970 reasoning_effort: None,
7971 responses_only_models: vec![],
7972 request_overrides: None,
7973 extra: BTreeMap::new(),
7974 api_key_from_env: false,
7975 };
7976
7977 let mut config = Config::default();
7979 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
7980 config
7981 .refresh_provider_api_keys_encrypted()
7982 .expect("refresh");
7983 assert_eq!(
7984 config
7985 .providers
7986 .openai
7987 .as_ref()
7988 .unwrap()
7989 .api_key_encrypted
7990 .as_deref(),
7991 Some("preexisting-ciphertext"),
7992 "existing ciphertext must be preserved when plaintext is empty"
7993 );
7994
7995 let mut config = Config::default();
7997 config.providers.openai = Some(openai("", None));
7998 config
7999 .refresh_provider_api_keys_encrypted()
8000 .expect("refresh");
8001 assert!(
8002 config
8003 .providers
8004 .openai
8005 .as_ref()
8006 .unwrap()
8007 .api_key_encrypted
8008 .is_none(),
8009 "no key + no ciphertext should stay None"
8010 );
8011
8012 let mut config = Config::default();
8014 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8015 config
8016 .refresh_provider_api_keys_encrypted()
8017 .expect("refresh");
8018 let enc = config
8019 .providers
8020 .openai
8021 .as_ref()
8022 .unwrap()
8023 .api_key_encrypted
8024 .clone()
8025 .expect("ciphertext present");
8026 assert!(
8027 !enc.is_empty() && enc != "stale-ciphertext",
8028 "plaintext re-encrypted"
8029 );
8030 }
8031
8032 #[test]
8033 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8034 let mut config = Config::default();
8040 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8041 "provider_type": "openai",
8042 "api_key": "sk-instance-live",
8043 }))
8044 .expect("valid instance");
8045 config
8046 .provider_instances
8047 .insert("work".to_string(), instance);
8048
8049 config.refresh_encrypted_secrets().expect("refresh");
8050 assert!(
8051 config.provider_instances["work"]
8052 .api_key_encrypted
8053 .is_some(),
8054 "live config must hold ciphertext after refresh"
8055 );
8056
8057 let value = serde_json::to_value(&config).expect("serialize");
8059 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8060 assert!(
8061 back.provider_instances["work"].api_key.is_empty(),
8062 "plaintext is skip_serializing"
8063 );
8064 back.hydrate_provider_instance_api_keys_from_encrypted();
8065 assert_eq!(
8066 back.provider_instances["work"].api_key, "sk-instance-live",
8067 "key must be recoverable from the round-tripped ciphertext"
8068 );
8069 }
8070
8071 #[test]
8072 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8073 let mut config = Config::default();
8074 config.features.provider_model_ref = false;
8075 config.provider = "openai".to_string();
8076 config.providers.openai = Some(OpenAIConfig {
8077 api_key: "test".to_string(),
8078 api_key_encrypted: None,
8079 credential_ref: None,
8080 base_url: None,
8081 model: Some("gpt-main".to_string()),
8082 fast_model: Some("gpt-fast".to_string()),
8083 vision_model: None,
8084 reasoning_effort: None,
8085 responses_only_models: vec![],
8086 request_overrides: None,
8087 extra: BTreeMap::new(),
8088 api_key_from_env: false,
8089 });
8090
8091 assert_eq!(
8092 config.get_memory_background_model().as_deref(),
8093 Some("gpt-fast")
8094 );
8095 }
8096
8097 #[test]
8098 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8099 let mut config = Config::default();
8100 config.features.provider_model_ref = false;
8101 config.provider = "openai".to_string();
8102 config.providers.openai = Some(OpenAIConfig {
8103 api_key: "sk-stale".to_string(),
8104 model: Some("legacy-main".to_string()),
8105 fast_model: Some("legacy-fast".to_string()),
8106 vision_model: Some("legacy-vision".to_string()),
8107 reasoning_effort: Some(ReasoningEffort::Low),
8108 ..OpenAIConfig::default()
8109 });
8110 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8111 "provider_type": "openai",
8112 "model": "instance-main",
8113 "fast_model": "instance-fast",
8114 "vision_model": "instance-vision",
8115 "reasoning_effort": "high",
8116 "enabled": true
8117 }))
8118 .unwrap();
8119 instance.api_key = "sk-instance".to_string();
8120 config
8121 .provider_instances
8122 .insert("work".to_string(), instance);
8123 config.default_provider_instance = Some("work".to_string());
8124
8125 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8126 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8127 assert_eq!(
8128 config.get_memory_background_model().as_deref(),
8129 Some("instance-fast")
8130 );
8131 assert_eq!(
8132 config.get_task_summary_model().as_deref(),
8133 Some("instance-fast")
8134 );
8135 assert_eq!(
8136 config.get_vision_model().as_deref(),
8137 Some("instance-vision")
8138 );
8139 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8140 }
8141
8142 #[test]
8143 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8144 let _provider =
8145 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8146 let _openai_key = crate::test_support::override_runtime_env_var(
8147 "BAMBOO_OPENAI_API_KEY",
8148 Some("sk-runtime-env-instance"),
8149 );
8150 let _anthropic_key =
8151 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8152 let _gemini_key =
8153 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8154
8155 let mut config = Config::default();
8156 *config.providers_mut() = ProviderConfigs::default();
8157 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8158 let mut extra = serde_json::Map::new();
8159 if from_environment {
8160 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8161 }
8162 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8163 extra.insert("enabled".to_string(), serde_json::json!(true));
8164 config.provider_instances.insert(
8165 id.to_string(),
8166 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8167 );
8168 }
8169
8170 config.apply_runtime_env_overrides();
8171
8172 assert_eq!(
8173 config.default_provider_instance.as_deref(),
8174 Some("a-marked"),
8175 "type override selects the lexicographically first enabled instance"
8176 );
8177 assert_eq!(
8178 config.provider_instances["a-marked"].api_key,
8179 "sk-runtime-env-instance"
8180 );
8181 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8182 assert!(
8183 config.providers().openai.is_none(),
8184 "instance-mode env hydration must not recreate a legacy alias"
8185 );
8186
8187 config.refresh_encrypted_secrets().unwrap();
8188 let serialized = serde_json::to_string(&config).unwrap();
8189 assert!(!serialized.contains("sk-runtime-env-instance"));
8190 assert!(
8191 config.provider_instances["a-marked"]
8192 .api_key_encrypted
8193 .is_none(),
8194 "runtime env plaintext must not be encrypted into ordinary config"
8195 );
8196 }
8197
8198 #[test]
8199 fn runtime_provider_override_prefers_exact_instance_id() {
8200 let _provider =
8201 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8202 let _openai_key =
8203 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8204 let _anthropic_key =
8205 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8206 let _gemini_key =
8207 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8208 let mut config = Config::default();
8209 for id in ["work", "personal"] {
8210 config.provider_instances.insert(
8211 id.to_string(),
8212 serde_json::from_value(serde_json::json!({
8213 "provider_type": "openai",
8214 "enabled": true
8215 }))
8216 .unwrap(),
8217 );
8218 }
8219
8220 config.apply_runtime_env_overrides();
8221 assert_eq!(
8222 config.default_provider_instance.as_deref(),
8223 Some("personal")
8224 );
8225 }
8226
8227 #[test]
8228 fn get_memory_background_model_does_not_fall_back_to_main_model() {
8229 let mut config = Config::default();
8230 config.features.provider_model_ref = false;
8231 config.provider = "openai".to_string();
8232 config.providers.openai = Some(OpenAIConfig {
8233 api_key: "test".to_string(),
8234 api_key_encrypted: None,
8235 credential_ref: None,
8236 base_url: None,
8237 model: Some("gpt-main".to_string()),
8238 fast_model: None,
8239 vision_model: None,
8240 reasoning_effort: None,
8241 responses_only_models: vec![],
8242 request_overrides: None,
8243 extra: BTreeMap::new(),
8244 api_key_from_env: false,
8245 });
8246
8247 assert!(config.get_memory_background_model().is_none());
8248 }
8249
8250 #[test]
8251 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8252 let legacy = serde_json::json!({
8253 "memory": MemoryConfig {
8254 background_model: Some("dream-fast".to_string()),
8255 summary_target_ratio: 0.20,
8256 summary_safe_window_percent: 80,
8257 auto_dream_enabled: true,
8258 auto_dream_interval_secs: 900,
8259 project_prompt_injection: false,
8260 relevant_recall: false,
8261 relevant_recall_rerank: true,
8262 project_first_dream: false,
8263 ledger_agenda_injection: false,
8264 ledger_gardener_enabled: false,
8265 ledger_gardener_interval_secs: 7_200,
8266 ledger_distillation_enabled: false,
8267 dream_refine_mode: true,
8268 gardener_enabled: true,
8269 gardener_interval_secs: 3_600,
8270 gardener_volume_trigger: 40,
8271 gardener_max_splits_per_run: 4,
8272 gardener_min_sections: 7,
8273 dedup_gardener_enabled: true,
8274 dedup_gardener_min_score: 0.7,
8275 dedup_gardener_max_merges_per_run: 3,
8276 memory_active_capacity: 500,
8277 capacity_max_archivals_per_run: 10,
8278 granularity_freshness_gardener_enabled: false,
8279 }
8280 });
8281 let config: Config = serde_json::from_value(legacy).unwrap();
8282
8283 let serialized = serde_json::to_value(&config).expect("config should serialize");
8284 assert!(serialized.get("memory").is_some());
8285 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8286 assert!(round_tripped
8287 .memory()
8288 .as_ref()
8289 .is_some_and(|memory| memory.dream_refine_mode));
8290 let memory = config.memory.as_ref().expect("memory config should exist");
8291 assert!(memory.auto_dream_enabled);
8292 assert!(!memory.project_prompt_injection);
8293 assert!(!memory.relevant_recall);
8294 assert!(memory.relevant_recall_rerank);
8295 assert!(!memory.project_first_dream);
8296 assert!(memory.dream_refine_mode);
8297 assert!(memory.gardener_enabled);
8298 assert_eq!(memory.gardener_interval_secs, 3_600);
8299 assert_eq!(memory.gardener_volume_trigger, 40);
8300 assert_eq!(memory.gardener_max_splits_per_run, 4);
8301 assert_eq!(memory.gardener_min_sections, 7);
8302 assert!(memory.dedup_gardener_enabled);
8303 assert_eq!(memory.dedup_gardener_min_score, 0.7);
8304 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8305 assert_eq!(memory.memory_active_capacity, 500);
8306 assert_eq!(memory.capacity_max_archivals_per_run, 10);
8307 assert!(!memory.granularity_freshness_gardener_enabled);
8308 }
8309
8310 #[test]
8312 fn memory_active_capacity_defaults_off() {
8313 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8314 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8315 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8316 let memory = parsed.memory.as_ref().unwrap();
8317 assert_eq!(memory.memory_active_capacity, 0);
8318 assert_eq!(
8319 memory.capacity_max_archivals_per_run, 50,
8320 "omitted field takes the serde default fn"
8321 );
8322 }
8323
8324 #[test]
8325 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8326 let defaults = MemoryConfig::default();
8327 assert_eq!(defaults.summary_target_ratio, 0.20);
8328 assert_eq!(defaults.summary_safe_window_percent, 80);
8329
8330 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8331 let memory = parsed.memory.as_ref().expect("memory present");
8332 assert_eq!(memory.summary_target_ratio, 0.20);
8333 assert_eq!(memory.summary_safe_window_percent, 80);
8334 }
8335
8336 #[test]
8340 fn memory_maintenance_integrators_default_on() {
8341 let defaults = MemoryConfig::default();
8342 assert!(defaults.auto_dream_enabled);
8343 assert!(defaults.gardener_enabled);
8344 assert!(defaults.dedup_gardener_enabled);
8345 assert_eq!(defaults.gardener_volume_trigger, 25);
8346
8347 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8349 let memory = parsed.memory.as_ref().expect("memory present");
8350 assert!(
8351 memory.auto_dream_enabled,
8352 "auto_dream on when field omitted"
8353 );
8354 assert!(memory.gardener_enabled, "gardener on when field omitted");
8355 assert!(
8356 memory.dedup_gardener_enabled,
8357 "dedup gardener on when field omitted"
8358 );
8359 let opted_out: Config =
8361 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8362 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8363 }
8364
8365 #[test]
8366 fn memory_config_env_overrides_prompt_flags() {
8367 let _lock = env_lock_acquire();
8368 let temp_home = TempHome::new();
8369 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8370 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8371 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8372 let _relevant_recall_rerank =
8373 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8374 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8375
8376 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8377 let memory = config
8378 .memory
8379 .as_ref()
8380 .expect("memory config should be created by env overrides");
8381 assert!(!memory.project_prompt_injection);
8382 assert!(!memory.relevant_recall);
8383 assert!(memory.relevant_recall_rerank);
8384 assert!(!memory.project_first_dream);
8385 }
8386
8387 #[test]
8388 fn provider_api_keys_injected_from_env_and_never_persisted() {
8389 let _lock = env_lock_acquire();
8390 let temp_home = TempHome::new();
8391 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8392 let _anthropic = crate::test_support::override_runtime_env_var(
8393 "BAMBOO_ANTHROPIC_API_KEY",
8394 Some("sk-ant-from-env"),
8395 );
8396 let _openai = crate::test_support::override_runtime_env_var(
8397 "BAMBOO_OPENAI_API_KEY",
8398 Some("sk-oai-from-env"),
8399 );
8400 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8401
8402 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8405 assert_eq!(
8406 config
8407 .providers
8408 .anthropic
8409 .as_ref()
8410 .expect("anthropic created from env")
8411 .api_key,
8412 "sk-ant-from-env"
8413 );
8414 assert_eq!(
8415 config
8416 .providers
8417 .openai
8418 .as_ref()
8419 .expect("openai created from env")
8420 .api_key,
8421 "sk-oai-from-env"
8422 );
8423 assert!(config.providers.gemini.is_none());
8425
8426 config
8432 .save_to_dir(temp_home.path.clone())
8433 .expect("save config");
8434 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8435 .expect("read persisted config.json");
8436 assert!(
8437 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8438 "env key must not be persisted as plaintext"
8439 );
8440 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8441 assert!(
8442 disk_json["providers"]["anthropic"]
8443 .get("api_key_encrypted")
8444 .is_none(),
8445 "env-sourced anthropic key must not be re-encrypted into config.json"
8446 );
8447 assert!(
8448 disk_json["providers"]["openai"]
8449 .get("api_key_encrypted")
8450 .is_none(),
8451 "env-sourced openai key must not be re-encrypted into config.json"
8452 );
8453
8454 drop(_anthropic);
8457 drop(_openai);
8458 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
8459 assert!(reloaded
8460 .providers
8461 .anthropic
8462 .as_ref()
8463 .map(|a| a.api_key.is_empty())
8464 .unwrap_or(true));
8465 }
8466
8467 #[test]
8468 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
8469 let _lock = env_lock_acquire();
8470 let temp_home = TempHome::new();
8471 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8472 let target = temp_home.path.join("workspace-default");
8473 std::fs::create_dir_all(&target).expect("default work area dir should exist");
8474
8475 let mut config = Config::default();
8476 config.default_work_area.replace(DefaultWorkAreaConfig {
8477 path: Some("~/workspace-default".to_string()),
8478 });
8479
8480 assert_eq!(config.get_default_work_area_path(), Some(target));
8481 }
8482
8483 #[test]
8484 fn get_default_work_area_path_returns_none_for_missing_directory() {
8485 let _lock = env_lock_acquire();
8486 let temp_home = TempHome::new();
8487 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8488
8489 let mut config = Config::default();
8490 config.default_work_area.replace(DefaultWorkAreaConfig {
8491 path: Some("~/missing-default-work-area".to_string()),
8492 });
8493
8494 assert!(config.get_default_work_area_path().is_none());
8495 }
8496
8497 #[test]
8498 fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
8499 let mut config = Config::default();
8500 config.tools.disabled = vec![
8501 " read_file ".to_string(),
8502 "".to_string(),
8503 "read_file".to_string(),
8504 "bash".to_string(),
8505 "default::getCurrentDir".to_string(),
8506 ];
8507
8508 config.normalize_tool_settings();
8509
8510 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
8511 }
8512
8513 #[test]
8514 fn config_load_reads_disabled_tools_as_canonical_names() {
8515 let _lock = env_lock_acquire();
8516 let temp_home = TempHome::new();
8517 temp_home.set_config_json(
8518 r#"{
8519 "tools": {
8520 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
8521 }
8522}"#,
8523 );
8524
8525 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8526 assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
8527 assert!(config.disabled_tool_names().contains("Bash"));
8528 assert!(config.disabled_tool_names().contains("Read"));
8529 assert!(config.disabled_tool_names().contains("GetCurrentDir"));
8530 }
8531
8532 #[test]
8533 fn normalize_skill_settings_trims_dedupes_and_sorts() {
8534 let mut config = Config::default();
8535 config.skills.disabled = vec![
8536 " pdf ".to_string(),
8537 "".to_string(),
8538 "pdf".to_string(),
8539 "skill-creator".to_string(),
8540 ];
8541
8542 config.normalize_skill_settings();
8543
8544 assert_eq!(
8545 config.skills.disabled,
8546 vec!["pdf".to_string(), "skill-creator".to_string()]
8547 );
8548 }
8549
8550 #[test]
8551 fn config_load_reads_disabled_skills_as_normalized_ids() {
8552 let _lock = env_lock_acquire();
8553 let temp_home = TempHome::new();
8554 temp_home.set_config_json(
8555 r#"{
8556 "skills": {
8557 "disabled": [" pdf ", "skill-creator", "pdf", ""]
8558 }
8559}"#,
8560 );
8561
8562 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8563 assert_eq!(
8564 config.skills.disabled,
8565 vec!["pdf".to_string(), "skill-creator".to_string()]
8566 );
8567 assert!(config.disabled_skill_ids().contains("pdf"));
8568 assert!(config.disabled_skill_ids().contains("skill-creator"));
8569 }
8570
8571 #[test]
8572 fn test_server_config_defaults() {
8573 let _lock = env_lock_acquire();
8574 let temp_home = TempHome::new();
8575
8576 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8577 assert_eq!(config.server.port, 9562);
8578 assert_eq!(config.server.bind, "127.0.0.1");
8579 assert_eq!(config.server.workers, 10);
8580 assert!(config.server.static_dir.is_none());
8581 }
8582
8583 #[test]
8584 fn test_server_addr() {
8585 let mut config = Config::default();
8586 config.server.port = 9000;
8587 config.server.bind = "0.0.0.0".to_string();
8588 assert_eq!(config.server_addr(), "0.0.0.0:9000");
8589 }
8590
8591 #[test]
8592 fn test_env_var_overrides() {
8593 let _lock = env_lock_acquire();
8594 let temp_home = TempHome::new();
8595
8596 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
8597 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
8598 let _provider =
8599 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8600
8601 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8602 assert_eq!(config.server.port, 9999);
8603 assert_eq!(config.server.bind, "192.168.1.1");
8604 assert_eq!(config.provider, "openai");
8605 }
8606
8607 #[test]
8608 fn test_config_save_and_load() {
8609 let _lock = env_lock_acquire();
8610 let temp_home = TempHome::new();
8611
8612 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8613 config.server.port = 9000;
8614 config.server.bind = "0.0.0.0".to_string();
8615 config.provider = "anthropic".to_string();
8616
8617 config
8619 .save_to_dir(temp_home.path.clone())
8620 .expect("Failed to save config");
8621
8622 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8624
8625 assert_eq!(loaded.server.port, 9000);
8627 assert_eq!(loaded.server.bind, "0.0.0.0");
8628 assert_eq!(loaded.provider, "anthropic");
8629 }
8630
8631 #[test]
8632 fn modular_root_and_public_serde_preserve_legacy_shape() {
8633 let _lock = env_lock_acquire();
8634 let temp_home = TempHome::new();
8635 let input = serde_json::json!({
8636 "http_proxy": "http://proxy.example",
8637 "provider": "openai",
8638 "mcp": { "servers": [] },
8639 "future_extension": { "enabled": true },
8640 "memory": { "background_model": "memory-model" },
8641 "subagents": { "max_concurrent": 7 },
8642 "providers": { "openai": { "model": "chat-model" } }
8643 });
8644
8645 let config: Config = serde_json::from_value(input).unwrap();
8646 let public = serde_json::to_value(&config).unwrap();
8647 assert_eq!(public["http_proxy"], "http://proxy.example");
8648 assert!(public.get("mcp").is_none());
8649 assert!(public.get("mcpServers").is_some());
8650 assert_eq!(public["future_extension"]["enabled"], true);
8651 assert_eq!(public["memory"]["background_model"], "memory-model");
8652 assert_eq!(public["subagents"]["max_concurrent"], 7);
8653 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
8654
8655 let round_tripped: Config = serde_json::from_value(public).unwrap();
8656 assert_eq!(
8657 round_tripped
8658 .memory()
8659 .as_ref()
8660 .unwrap()
8661 .background_model
8662 .as_deref(),
8663 Some("memory-model")
8664 );
8665 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
8666 assert_eq!(
8667 round_tripped
8668 .providers()
8669 .openai
8670 .as_ref()
8671 .unwrap()
8672 .model
8673 .as_deref(),
8674 Some("chat-model")
8675 );
8676 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
8677
8678 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
8679 let persisted: Value =
8680 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
8681 .unwrap();
8682 assert_eq!(persisted["http_proxy"], "http://proxy.example");
8683 assert!(persisted.get("mcpServers").is_some());
8684 assert_eq!(persisted["future_extension"]["enabled"], true);
8685 assert!(persisted.get("memory").is_none());
8686 assert!(persisted.get("subagents").is_none());
8687 assert!(persisted.get("providers").is_none());
8688 for internal_section_name in [
8689 "network",
8690 "provider_routing",
8691 "model_behavior",
8692 "tooling",
8693 "workspace",
8694 "execution",
8695 "integrations",
8696 "plugin_security",
8697 ] {
8698 assert!(persisted.get(internal_section_name).is_none());
8699 }
8700 }
8701
8702 #[test]
8703 fn config_decrypts_proxy_auth_from_encrypted_field() {
8704 let _lock = env_lock_acquire();
8705 let temp_home = TempHome::new();
8706
8707 let key_guard = crate::encryption::set_test_encryption_key([
8709 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8710 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8711 0x1c, 0x1d, 0x1e, 0x1f,
8712 ]);
8713
8714 let auth = ProxyAuth {
8715 username: "user".to_string(),
8716 password: "pass".to_string(),
8717 };
8718 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
8719 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
8720
8721 temp_home.set_config_json(&format!(
8722 r#"{{
8723 "http_proxy": "http://proxy.example.com:8080",
8724 "proxy_auth_encrypted": "{encrypted}"
8725}}"#
8726 ));
8727 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8728 let loaded_auth = config
8729 .proxy_auth
8730 .as_ref()
8731 .expect("proxy auth should be hydrated");
8732 assert_eq!(loaded_auth.username, "user");
8733 assert_eq!(loaded_auth.password, "pass");
8734 drop(key_guard);
8735 }
8736
8737 #[test]
8738 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
8739 let _lock = env_lock_acquire();
8740 let temp_home = TempHome::new();
8741
8742 let key_guard = crate::encryption::set_test_encryption_key([
8744 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8745 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8746 0x1c, 0x1d, 0x1e, 0x1f,
8747 ]);
8748
8749 let auth = ProxyAuth {
8750 username: "user".to_string(),
8751 password: "pass".to_string(),
8752 };
8753 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
8754 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
8755
8756 temp_home.set_config_json(&format!(
8758 r#"{{
8759 "http_proxy": "http://proxy.example.com:8080",
8760 "http_proxy_auth_encrypted": "{encrypted}",
8761 "https_proxy_auth_encrypted": "{encrypted}"
8762}}"#
8763 ));
8764
8765 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8766 let loaded_auth = config
8767 .proxy_auth
8768 .as_ref()
8769 .expect("proxy auth should be hydrated");
8770 assert_eq!(loaded_auth.username, "user");
8771 assert_eq!(loaded_auth.password, "pass");
8772 drop(key_guard);
8773 }
8774
8775 #[test]
8776 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
8777 let _lock = env_lock_acquire();
8778 let temp_home = TempHome::new();
8779
8780 let key_guard = crate::encryption::set_test_encryption_key([
8782 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8783 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8784 0x1c, 0x1d, 0x1e, 0x1f,
8785 ]);
8786
8787 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8788 config.proxy_auth = Some(ProxyAuth {
8789 username: "user".to_string(),
8790 password: "pass".to_string(),
8791 });
8792 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
8793 assert!(error
8794 .to_string()
8795 .contains("isolated credential transaction"));
8796 let path = temp_home.path.join("config.json");
8797 assert!(
8798 !path.exists(),
8799 "a rejected unisolated secret must not create config.json"
8800 );
8801 drop(key_guard);
8802 }
8803
8804 #[test]
8805 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
8806 let _lock = env_lock_acquire();
8807 let temp_home = TempHome::new();
8808 let mut config = Config::default();
8809 config.env_vars.push(EnvVarEntry {
8810 name: "TOKEN".to_string(),
8811 value: String::new(),
8812 secret: true,
8813 value_encrypted: None,
8814 credential_ref: None,
8815 configured: true,
8816 description: None,
8817 });
8818 let path = temp_home.path.join("config.json");
8819
8820 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
8821 assert!(error
8822 .to_string()
8823 .contains("isolated credential transaction"));
8824 assert!(
8825 !path.exists(),
8826 "a rejected dangling ref must not create config.json"
8827 );
8828
8829 let original = br#"{"preserve":"original"}"#;
8830 std::fs::write(&path, original).unwrap();
8831 config.save_to_dir(temp_home.path.clone()).unwrap_err();
8832 assert_eq!(
8833 std::fs::read(&path).unwrap(),
8834 original,
8835 "a rejected dangling ref must not modify an existing config.json"
8836 );
8837 }
8838
8839 #[test]
8840 fn config_save_persists_provider_reference_and_isolates_plaintext() {
8841 let _lock = env_lock_acquire();
8842 let temp_home = TempHome::new();
8843
8844 let key_guard = crate::encryption::set_test_encryption_key([
8846 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
8847 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
8848 0x1c, 0x1d, 0x1e, 0x1f,
8849 ]);
8850
8851 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
8852 crate::CredentialStore::open(&temp_home.path)
8853 .replace(
8854 reference.clone(),
8855 "sk-test-provider-key",
8856 crate::CredentialSource::User,
8857 0,
8858 )
8859 .unwrap();
8860 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8861 config.provider = "openai".to_string();
8862 config.providers.openai = Some(OpenAIConfig {
8863 api_key: "sk-test-provider-key".to_string(),
8864 api_key_encrypted: None,
8865 credential_ref: Some(reference),
8866 base_url: None,
8867 model: None,
8868 fast_model: None,
8869 vision_model: None,
8870 reasoning_effort: None,
8871 responses_only_models: vec![],
8872 request_overrides: None,
8873 extra: Default::default(),
8874 api_key_from_env: false,
8875 });
8876
8877 config
8878 .save_to_dir(temp_home.path.clone())
8879 .expect("save should persist provider reference");
8880
8881 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
8882 .expect("read providers.json");
8883 assert!(
8884 !content.contains("api_key_encrypted"),
8885 "providers.json must not store provider ciphertext"
8886 );
8887 assert!(
8888 !content.contains("\"api_key\""),
8889 "providers.json should not store plaintext provider keys"
8890 );
8891
8892 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8893 let openai = loaded
8894 .providers
8895 .openai
8896 .as_ref()
8897 .expect("openai config should be present");
8898 assert_eq!(openai.api_key, "sk-test-provider-key");
8899
8900 drop(key_guard);
8901 }
8902
8903 #[test]
8904 fn config_save_persists_mcp_servers_in_mainstream_format() {
8905 let _lock = env_lock_acquire();
8906 let temp_home = TempHome::new();
8907
8908 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8909
8910 let mut env = std::collections::HashMap::new();
8911 env.insert("TOKEN".to_string(), "supersecret".to_string());
8912
8913 config.mcp.servers = vec![
8914 bamboo_domain::mcp_config::McpServerConfig {
8915 id: "stdio-secret".to_string(),
8916 name: None,
8917 enabled: true,
8918 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
8919 bamboo_domain::mcp_config::StdioConfig {
8920 command: "echo".to_string(),
8921 args: vec![],
8922 cwd: None,
8923 env,
8924 env_encrypted: std::collections::HashMap::new(),
8925 env_credential_refs: std::collections::HashMap::new(),
8926 startup_timeout_ms: 5000,
8927 },
8928 ),
8929 request_timeout_ms: 5000,
8930 healthcheck_interval_ms: 1000,
8931 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
8932 allowed_tools: vec![],
8933 denied_tools: vec![],
8934 },
8935 bamboo_domain::mcp_config::McpServerConfig {
8936 id: "sse-secret".to_string(),
8937 name: None,
8938 enabled: true,
8939 transport: bamboo_domain::mcp_config::TransportConfig::Sse(
8940 bamboo_domain::mcp_config::SseConfig {
8941 url: "http://localhost:8080/sse".to_string(),
8942 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
8943 name: "Authorization".to_string(),
8944 value: "Bearer token123".to_string(),
8945 value_encrypted: None,
8946 credential_ref: None,
8947 }],
8948 connect_timeout_ms: 5000,
8949 },
8950 ),
8951 request_timeout_ms: 5000,
8952 healthcheck_interval_ms: 1000,
8953 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
8954 allowed_tools: vec![],
8955 denied_tools: vec![],
8956 },
8957 ];
8958
8959 config
8960 .save_to_dir(temp_home.path.clone())
8961 .expect("save should persist MCP servers");
8962
8963 let content =
8964 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
8965 assert!(
8966 content.contains("\"mcpServers\""),
8967 "config.json should store MCP servers under the mainstream 'mcpServers' key"
8968 );
8969 assert!(
8970 content.contains("supersecret"),
8971 "config.json should persist MCP stdio env in mainstream format"
8972 );
8973 assert!(
8974 content.contains("Bearer token123"),
8975 "config.json should persist MCP SSE headers in mainstream format"
8976 );
8977 assert!(
8978 !content.contains("\"env_encrypted\""),
8979 "config.json should not persist legacy env_encrypted fields"
8980 );
8981 assert!(
8982 !content.contains("\"value_encrypted\""),
8983 "config.json should not persist legacy value_encrypted fields"
8984 );
8985
8986 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8987 let stdio = loaded
8988 .mcp
8989 .servers
8990 .iter()
8991 .find(|s| s.id == "stdio-secret")
8992 .expect("stdio server should exist");
8993 match &stdio.transport {
8994 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
8995 assert_eq!(
8996 stdio.env.get("TOKEN").map(|s| s.as_str()),
8997 Some("supersecret")
8998 );
8999 }
9000 _ => panic!("Expected stdio transport"),
9001 }
9002
9003 let sse = loaded
9004 .mcp
9005 .servers
9006 .iter()
9007 .find(|s| s.id == "sse-secret")
9008 .expect("sse server should exist");
9009 match &sse.transport {
9010 bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
9011 assert_eq!(sse.headers[0].value, "Bearer token123");
9012 }
9013 _ => panic!("Expected SSE transport"),
9014 }
9015 }
9016
9017 #[test]
9018 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9019 let _lock = env_lock_acquire();
9020 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9021 let temp_home = TempHome::new();
9022 let env_plaintext = "mcp-root-env-plaintext-597";
9023 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9024 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9025 std::fs::write(
9026 temp_home.path.join("config.json"),
9027 serde_json::to_vec_pretty(&serde_json::json!({
9028 "features": {"provider_model_ref": true},
9029 "mcpServers": {
9030 "stdio-root": {
9031 "command": "unused-disabled-command",
9032 "env": {"TOKEN": env_plaintext},
9033 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9034 "env_credential_refs": {
9035 "TOKEN": "mcp.stdio-root.env_TOKEN"
9036 }
9037 },
9038 "http-root": {
9039 "url": "https://example.test/mcp",
9040 "transport_kind": "streamable_http",
9041 "headers": {"Authorization": header_plaintext},
9042 "header_credential_refs": {
9043 "Authorization": "mcp.http-root.header_Authorization"
9044 }
9045 }
9046 }
9047 }))
9048 .unwrap(),
9049 )
9050 .unwrap();
9051 std::fs::write(
9052 temp_home.path.join("mcp.json"),
9053 serde_json::to_vec_pretty(&serde_json::json!({
9054 "schema_version": 1,
9055 "revision": 7,
9056 "data": {
9057 "stdio-root": {
9058 "command": "unused-disabled-command",
9059 "enabled": false,
9060 "env_encrypted": {"TOKEN": legacy_ciphertext},
9061 "request_timeout_ms": 100,
9062 "healthcheck_interval_ms": 100
9063 },
9064 "http-root": {
9065 "url": "https://example.test/mcp",
9066 "transport_kind": "streamable_http",
9067 "enabled": false,
9068 "headers": {"Authorization": header_plaintext},
9069 "request_timeout_ms": 100,
9070 "healthcheck_interval_ms": 100
9071 }
9072 }
9073 }))
9074 .unwrap(),
9075 )
9076 .unwrap();
9077
9078 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9079 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9080 temp_home.path.join("mcp.json"),
9081 1,
9082 )
9083 .load()
9084 .unwrap()
9085 .unwrap();
9086 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9087 config.mcp = stored.data;
9088 config
9089 .hydrate_mcp_credentials_from_store(&temp_home.path)
9090 .unwrap();
9091
9092 let public = serde_json::to_value(&config.mcp).unwrap();
9093 let rendered_public = public.to_string();
9094 assert!(rendered_public.contains(env_plaintext));
9095 assert!(rendered_public.contains(header_plaintext));
9096 let compatible: bamboo_domain::mcp_config::McpConfig =
9097 serde_json::from_value(public).unwrap();
9098 assert_eq!(compatible.servers.len(), 2);
9099
9100 config.features.provider_model_ref = !config.features.provider_model_ref;
9101 config.save_to_dir(temp_home.path.clone()).unwrap();
9102 config.features.provider_model_ref = !config.features.provider_model_ref;
9103 config.save_to_dir(temp_home.path.clone()).unwrap();
9104
9105 for entry in std::fs::read_dir(&temp_home.path)
9106 .unwrap()
9107 .filter_map(Result::ok)
9108 {
9109 let name = entry.file_name().to_string_lossy().to_string();
9110 if name == "credentials.json" || entry.path().is_dir() {
9111 continue;
9112 }
9113 if name == "config.json"
9114 || name == "mcp.json"
9115 || name.contains(".bak")
9116 || name.starts_with("config-credential-migration")
9117 {
9118 let bytes = std::fs::read(entry.path()).unwrap();
9119 let content = String::from_utf8_lossy(&bytes);
9120 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9121 assert!(
9122 !content.contains(header_plaintext),
9123 "secret leaked to {name}"
9124 );
9125 assert!(
9126 !content.contains(&legacy_ciphertext),
9127 "legacy ciphertext leaked to {name}"
9128 );
9129 }
9130 }
9131 }
9132
9133 #[test]
9136 fn env_vars_as_map_includes_only_non_empty_values() {
9137 let mut config = Config::default();
9138 config.env_vars.extend([
9139 EnvVarEntry {
9140 name: "A".to_string(),
9141 value: "val_a".to_string(),
9142 secret: false,
9143 value_encrypted: None,
9144 credential_ref: None,
9145 configured: true,
9146 description: None,
9147 },
9148 EnvVarEntry {
9149 name: "B".to_string(),
9150 value: "".to_string(), secret: true,
9152 value_encrypted: None,
9153 credential_ref: None,
9154 configured: false,
9155 description: None,
9156 },
9157 EnvVarEntry {
9158 name: "C".to_string(),
9159 value: " ".to_string(), secret: false,
9161 value_encrypted: None,
9162 credential_ref: None,
9163 configured: true,
9164 description: None,
9165 },
9166 EnvVarEntry {
9167 name: "D".to_string(),
9168 value: "val_d".to_string(),
9169 secret: true,
9170 value_encrypted: Some("enc".to_string()),
9171 credential_ref: None,
9172 configured: true,
9173 description: Some("desc".to_string()),
9174 },
9175 ]);
9176
9177 let map = config.env_vars_as_map();
9178 assert_eq!(map.len(), 2);
9179 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9180 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9181 assert!(!map.contains_key("B"));
9182 assert!(!map.contains_key("C"));
9183 }
9184
9185 #[test]
9186 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9187 let mut config = Config::default();
9188 config.env_vars.extend([
9189 EnvVarEntry {
9190 name: "PLAIN".to_string(),
9191 value: "visible".to_string(),
9192 secret: false,
9193 value_encrypted: None,
9194 credential_ref: None,
9195 configured: true,
9196 description: None,
9197 },
9198 EnvVarEntry {
9199 name: "SECRET".to_string(),
9200 value: "hidden_value".to_string(),
9201 secret: true,
9202 value_encrypted: Some("enc_data".to_string()),
9203 credential_ref: None,
9204 configured: true,
9205 description: None,
9206 },
9207 ]);
9208
9209 config.sanitize_env_vars_for_disk();
9210
9211 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
9214
9215 #[test]
9216 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9217 let mut config = Config::default();
9218 config.env_vars.extend([
9219 EnvVarEntry {
9220 name: "OPEN".to_string(),
9221 value: "val".to_string(),
9222 secret: false,
9223 value_encrypted: None,
9224 credential_ref: None,
9225 configured: true,
9226 description: None,
9227 },
9228 EnvVarEntry {
9229 name: "HIDDEN".to_string(),
9230 value: "real_secret".to_string(),
9231 secret: true,
9232 value_encrypted: Some("enc".to_string()),
9233 credential_ref: None,
9234 configured: true,
9235 description: None,
9236 },
9237 ]);
9238
9239 config.sanitize_env_vars_for_disk();
9240
9241 assert_eq!(config.env_vars[0].value, "val");
9243 assert_eq!(config.env_vars[1].value, "");
9245 assert!(config.env_vars[1].value_encrypted.is_none());
9246 }
9247
9248 #[test]
9249 fn legacy_env_ciphertext_is_read_but_never_serialized() {
9250 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9251 let mut config: Config = serde_json::from_value(serde_json::json!({
9252 "env_vars": [{
9253 "name": "TOKEN",
9254 "secret": true,
9255 "value_encrypted": ciphertext
9256 }]
9257 }))
9258 .unwrap();
9259 config.hydrate_env_vars_from_encrypted();
9260 assert_eq!(config.env_vars[0].value, "my-secret-token");
9261 let serialized = serde_json::to_value(&config).unwrap();
9262 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9263 }
9264
9265 #[test]
9266 fn configured_env_ref_missing_from_store_fails_closed() {
9267 let dir = tempfile::tempdir().unwrap();
9268 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9269 let mut config = Config::default();
9270 config.env_vars.push(EnvVarEntry {
9271 name: "TOKEN".to_string(),
9272 value: String::new(),
9273 secret: true,
9274 value_encrypted: None,
9275 credential_ref: Some(reference),
9276 configured: true,
9277 description: None,
9278 });
9279 let error = config
9280 .hydrate_env_var_credentials_from_store(dir.path())
9281 .unwrap_err();
9282 assert!(error
9283 .to_string()
9284 .contains("referenced env credential is unavailable"));
9285 assert!(config.env_vars[0].configured);
9286 assert!(config.env_vars[0].value.is_empty());
9287 }
9288
9289 #[test]
9290 fn configured_notification_ref_missing_from_store_fails_closed() {
9291 let dir = tempfile::tempdir().unwrap();
9292 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9293 let mut config = Config::default();
9294 config.notifications.ntfy.credential_ref = Some(reference);
9295 config.notifications.ntfy.configured = true;
9296
9297 let error = config
9298 .hydrate_notification_credentials_from_store(dir.path())
9299 .unwrap_err();
9300
9301 assert!(error
9302 .to_string()
9303 .contains("referenced ntfy credential is unavailable"));
9304 assert!(config.notifications.ntfy.configured);
9305 assert!(config.notifications.ntfy.token.is_none());
9306 }
9307
9308 #[test]
9309 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9310 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9311 let dir = tempfile::tempdir().unwrap();
9312 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9313 crate::CredentialStore::open(dir.path())
9314 .replace(
9315 reference.clone(),
9316 "exclusive-notification-secret",
9317 crate::CredentialSource::User,
9318 0,
9319 )
9320 .unwrap();
9321
9322 let mut config = Config::default();
9323 config.notifications.ntfy.credential_ref = Some(reference.clone());
9324 config.notifications.ntfy.configured = true;
9325 config.proxy_auth_credential_ref = Some(reference.clone());
9326 let error = config
9327 .hydrate_notification_credentials_from_store(dir.path())
9328 .unwrap_err();
9329 let rendered = error.to_string();
9330 assert!(rendered
9331 .contains("notification credential reference is shared by another config consumer"));
9332 assert!(!rendered.contains(reference.as_str()));
9333 assert!(config.notifications.ntfy.token.is_none());
9334
9335 config.proxy_auth_credential_ref = None;
9336 config
9337 .hydrate_notification_credentials_from_store(dir.path())
9338 .unwrap();
9339 assert_eq!(
9340 config.notifications.ntfy.token.as_deref(),
9341 Some("exclusive-notification-secret")
9342 );
9343 }
9344
9345 #[test]
9346 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9347 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9348 let dir = tempfile::tempdir().unwrap();
9349 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9350 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9351 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9352 "notifications": {
9353 "ntfy": { "enabled": true, "token_encrypted": ntfy },
9354 "bark": { "enabled": true, "device_key_encrypted": bark }
9355 }
9356 }))
9357 .unwrap();
9358 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9359 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9360
9361 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9362
9363 assert!(loaded.notifications.ntfy.token.is_none());
9364 assert!(loaded.notifications.bark.device_key.is_none());
9365 assert_eq!(
9366 std::fs::read(dir.path().join("config.json")).unwrap(),
9367 bytes
9368 );
9369 }
9370
9371 #[test]
9372 fn publish_and_current_env_vars_round_trip() {
9373 let _lock = crate::test_support::env_cache_lock_acquire();
9383 let mut config = Config::default();
9384 config.env_vars.extend([EnvVarEntry {
9385 name: "TEST_PUBLISH".to_string(),
9386 value: "pub_value".to_string(),
9387 secret: false,
9388 value_encrypted: None,
9389 credential_ref: None,
9390 configured: true,
9391 description: None,
9392 }]);
9393
9394 config.publish_env_vars();
9395 assert_eq!(
9396 Config::current_env_vars()
9397 .get("TEST_PUBLISH")
9398 .map(String::as_str),
9399 Some("pub_value")
9400 );
9401 }
9402
9403 #[test]
9404 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9405 let mut config = Config::default();
9406 config.subagents.broker = Some(BrokerClientConfig {
9407 endpoint: "ws://127.0.0.1:9600".to_string(),
9408 token: "super-secret-token".to_string(),
9409 token_encrypted: None,
9410 credential_ref: None,
9411 configured: false,
9412 });
9413
9414 config.refresh_broker_token_encrypted().unwrap();
9416 config.sanitize_broker_token_for_disk();
9417 let broker = config.subagents.broker.as_ref().unwrap();
9418 assert!(broker.token.is_empty(), "plaintext cleared for disk");
9419 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9420 assert_ne!(
9421 broker.token_encrypted.as_deref(),
9422 Some("super-secret-token")
9423 );
9424
9425 config.hydrate_broker_token_from_encrypted();
9427 assert_eq!(
9428 config.subagents.broker.as_ref().unwrap().token,
9429 "super-secret-token"
9430 );
9431 }
9432
9433 #[test]
9434 fn broker_token_empty_refresh_preserves_ciphertext() {
9435 let mut config = Config::default();
9437 config.subagents.broker = Some(BrokerClientConfig {
9438 endpoint: "ws://h:9600".to_string(),
9439 token: String::new(),
9440 token_encrypted: Some("existing-cipher".to_string()),
9441 credential_ref: None,
9442 configured: false,
9443 });
9444 config.refresh_broker_token_encrypted().unwrap();
9445 assert_eq!(
9446 config
9447 .subagents
9448 .broker
9449 .as_ref()
9450 .unwrap()
9451 .token_encrypted
9452 .as_deref(),
9453 Some("existing-cipher"),
9454 );
9455 }
9456
9457 #[test]
9458 fn notifications_config_defaults_when_key_missing() {
9459 let config: Config = serde_json::from_str("{}").expect("empty object parses");
9462 assert_eq!(config.notifications, NotificationsConfig::default());
9463 assert_eq!(config.notifications.desktop.enabled, None);
9464 assert!(!config.notifications.ntfy.enabled);
9465 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
9466 assert_eq!(config.notifications.ntfy.token, None);
9467 assert!(!config.notifications.bark.enabled);
9468 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
9469 assert_eq!(config.notifications.bark.device_key, None);
9470 }
9471
9472 #[test]
9473 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
9474 let mut config = Config::default();
9475 config.notifications.ntfy = NtfyChannelConfig {
9476 enabled: true,
9477 base_url: "https://ntfy.sh".to_string(),
9478 topic: "bamboo-alerts".to_string(),
9479 token: Some("tk_super_secret".to_string()),
9480 token_encrypted: None,
9481 credential_ref: None,
9482 configured: false,
9483 };
9484
9485 config.refresh_notifications_encrypted().unwrap();
9488 assert!(config.notifications.ntfy.token_encrypted.is_some());
9489 assert_ne!(
9490 config.notifications.ntfy.token_encrypted.as_deref(),
9491 Some("tk_super_secret")
9492 );
9493
9494 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
9496 assert!(
9497 !json.contains("tk_super_secret"),
9498 "plaintext token must never be serialized"
9499 );
9500 assert!(!json.contains("token_encrypted"));
9501
9502 config.notifications.ntfy.token = None;
9504 config.hydrate_notifications_from_encrypted();
9505 assert_eq!(
9506 config.notifications.ntfy.token.as_deref(),
9507 Some("tk_super_secret")
9508 );
9509 }
9510
9511 #[test]
9512 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
9513 let mut config = Config::default();
9514 config.notifications.bark = BarkChannelConfig {
9515 enabled: true,
9516 base_url: "https://api.day.app".to_string(),
9517 device_key: Some("dk_super_secret".to_string()),
9518 device_key_encrypted: None,
9519 credential_ref: None,
9520 configured: false,
9521 };
9522
9523 config.refresh_notifications_encrypted().unwrap();
9524 assert!(config.notifications.bark.device_key_encrypted.is_some());
9525 assert_ne!(
9526 config.notifications.bark.device_key_encrypted.as_deref(),
9527 Some("dk_super_secret")
9528 );
9529
9530 let json = serde_json::to_string(&config.notifications.bark).unwrap();
9531 assert!(
9532 !json.contains("dk_super_secret"),
9533 "plaintext device key must never be serialized"
9534 );
9535 assert!(!json.contains("device_key_encrypted"));
9536
9537 config.notifications.bark.device_key = None;
9538 config.hydrate_notifications_from_encrypted();
9539 assert_eq!(
9540 config.notifications.bark.device_key.as_deref(),
9541 Some("dk_super_secret")
9542 );
9543 }
9544
9545 #[test]
9546 fn notification_secrets_empty_refresh_preserves_ciphertext() {
9547 let mut config = Config::default();
9550 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
9551 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
9552
9553 config.refresh_notifications_encrypted().unwrap();
9554
9555 assert_eq!(
9556 config.notifications.ntfy.token_encrypted.as_deref(),
9557 Some("existing-ntfy-cipher")
9558 );
9559 assert_eq!(
9560 config.notifications.bark.device_key_encrypted.as_deref(),
9561 Some("existing-bark-cipher")
9562 );
9563 }
9564
9565 #[test]
9566 fn hydrate_skips_non_secret_entries() {
9567 let mut config = Config::default();
9568 config.env_vars.extend([EnvVarEntry {
9569 name: "PLAIN".to_string(),
9570 value: "original".to_string(),
9571 secret: false,
9572 value_encrypted: Some("should-be-ignored".to_string()),
9573 credential_ref: None,
9574 configured: true,
9575 description: None,
9576 }]);
9577
9578 config.hydrate_env_vars_from_encrypted();
9579 assert_eq!(config.env_vars[0].value, "original");
9581 }
9582
9583 #[test]
9584 fn default_config_has_empty_env_vars() {
9585 assert!(Config::default().env_vars.is_empty());
9590 }
9591
9592 #[test]
9593 fn serde_round_trip_with_env_vars() {
9594 let mut config = Config::default();
9595 config.env_vars.extend([
9596 EnvVarEntry {
9597 name: "KEY1".to_string(),
9598 value: "val1".to_string(),
9599 secret: false,
9600 value_encrypted: None,
9601 credential_ref: None,
9602 configured: true,
9603 description: Some("First key".to_string()),
9604 },
9605 EnvVarEntry {
9606 name: "KEY2".to_string(),
9607 value: "".to_string(), secret: true,
9609 value_encrypted: Some("enc123".to_string()),
9610 credential_ref: None,
9611 configured: true,
9612 description: None,
9613 },
9614 ]);
9615
9616 let json = serde_json::to_string(&config).unwrap();
9617 let restored: Config = serde_json::from_str(&json).unwrap();
9618
9619 assert_eq!(restored.env_vars.len(), 2);
9620 assert_eq!(restored.env_vars[0].name, "KEY1");
9621 assert_eq!(restored.env_vars[0].value, "val1");
9622 assert!(!restored.env_vars[0].secret);
9623 assert_eq!(restored.env_vars[1].name, "KEY2");
9624 assert!(restored.env_vars[1].secret);
9625 assert!(restored.env_vars[1].value_encrypted.is_none());
9626 }
9627
9628 #[test]
9631 #[allow(clippy::field_reassign_with_default)]
9633 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
9634 let mut config = Config::default();
9635 config.provider = "openai".to_string();
9636 config.providers.openai = Some(OpenAIConfig {
9637 api_key: "test".to_string(),
9638 api_key_encrypted: None,
9639 credential_ref: None,
9640 base_url: None,
9641 model: Some("legacy-gpt-4o".to_string()),
9642 fast_model: None,
9643 vision_model: None,
9644 reasoning_effort: None,
9645 responses_only_models: vec![],
9646 request_overrides: None,
9647 extra: Default::default(),
9648 api_key_from_env: false,
9649 });
9650 config.features.provider_model_ref = true;
9651 config.defaults = Some(DefaultsConfig {
9652 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9653 fast: None,
9654 task_summary: None,
9655 vision: None,
9656 memory_background: None,
9657 planning: None,
9658 search: None,
9659 code_review: None,
9660 sub_agent: None,
9661 subagent_models: Default::default(),
9662 });
9663
9664 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
9665 }
9666
9667 #[test]
9668 #[allow(clippy::field_reassign_with_default)]
9670 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
9671 let mut config = Config::default();
9672 config.provider = "openai".to_string();
9673 config.providers.openai = Some(OpenAIConfig {
9674 api_key: "test".to_string(),
9675 api_key_encrypted: None,
9676 credential_ref: None,
9677 base_url: None,
9678 model: Some("legacy-gpt-4o".to_string()),
9679 fast_model: None,
9680 vision_model: None,
9681 reasoning_effort: None,
9682 responses_only_models: vec![],
9683 request_overrides: None,
9684 extra: Default::default(),
9685 api_key_from_env: false,
9686 });
9687 config.features.provider_model_ref = false;
9688 config.defaults = Some(DefaultsConfig {
9689 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9690 fast: None,
9691 task_summary: None,
9692 vision: None,
9693 memory_background: None,
9694 planning: None,
9695 search: None,
9696 code_review: None,
9697 sub_agent: None,
9698 subagent_models: Default::default(),
9699 });
9700
9701 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
9702 }
9703
9704 #[test]
9705 #[allow(clippy::field_reassign_with_default)]
9707 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
9708 let mut config = Config::default();
9709 config.provider = "openai".to_string();
9710 config.providers.openai = Some(OpenAIConfig {
9711 api_key: "test".to_string(),
9712 api_key_encrypted: None,
9713 credential_ref: None,
9714 base_url: None,
9715 model: Some("gpt-4o".to_string()),
9716 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9717 vision_model: None,
9718 reasoning_effort: None,
9719 responses_only_models: vec![],
9720 request_overrides: None,
9721 extra: Default::default(),
9722 api_key_from_env: false,
9723 });
9724 config.features.provider_model_ref = true;
9725 config.defaults = Some(DefaultsConfig {
9726 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9727 fast: Some(bamboo_domain::ProviderModelRef::new(
9728 "anthropic",
9729 "claude-3-5-haiku",
9730 )),
9731 task_summary: None,
9732 vision: None,
9733 memory_background: None,
9734 planning: None,
9735 search: None,
9736 code_review: None,
9737 sub_agent: None,
9738 subagent_models: Default::default(),
9739 });
9740
9741 assert_eq!(
9742 config.get_fast_model(),
9743 Some("claude-3-5-haiku".to_string())
9744 );
9745 }
9746
9747 #[test]
9748 #[allow(clippy::field_reassign_with_default)]
9750 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
9751 let mut config = Config::default();
9752 config.provider = "openai".to_string();
9753 config.providers.openai = Some(OpenAIConfig {
9754 api_key: "test".to_string(),
9755 api_key_encrypted: None,
9756 credential_ref: None,
9757 base_url: None,
9758 model: Some("gpt-4o".to_string()),
9759 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9760 vision_model: None,
9761 reasoning_effort: None,
9762 responses_only_models: vec![],
9763 request_overrides: None,
9764 extra: Default::default(),
9765 api_key_from_env: false,
9766 });
9767 config.features.provider_model_ref = false;
9768 config.defaults = Some(DefaultsConfig {
9769 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9770 fast: Some(bamboo_domain::ProviderModelRef::new(
9771 "anthropic",
9772 "claude-3-5-haiku",
9773 )),
9774 task_summary: None,
9775 vision: None,
9776 memory_background: None,
9777 planning: None,
9778 search: None,
9779 code_review: None,
9780 sub_agent: None,
9781 subagent_models: Default::default(),
9782 });
9783
9784 assert_eq!(
9785 config.get_fast_model(),
9786 Some("legacy-gpt-4o-mini".to_string())
9787 );
9788 }
9789
9790 #[test]
9791 #[allow(clippy::field_reassign_with_default)]
9793 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
9794 let mut config = Config::default();
9795 config.provider = "openai".to_string();
9796 config.features.provider_model_ref = true;
9797 config.defaults = Some(DefaultsConfig {
9798 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9799 fast: None,
9800 task_summary: None,
9801 vision: None,
9802 memory_background: None,
9803 planning: None,
9804 search: None,
9805 code_review: None,
9806 sub_agent: None,
9807 subagent_models: Default::default(),
9808 });
9809
9810 assert_eq!(
9811 config.get_fast_model(),
9812 Some("claude-3-7-sonnet".to_string())
9813 );
9814 }
9815
9816 #[test]
9817 #[allow(clippy::field_reassign_with_default)]
9819 fn get_memory_background_model_prefers_defaults_memory_background() {
9820 let mut config = Config::default();
9821 config.provider = "openai".to_string();
9822 config.providers.openai = Some(OpenAIConfig {
9823 api_key: "test".to_string(),
9824 api_key_encrypted: None,
9825 credential_ref: None,
9826 base_url: None,
9827 model: Some("gpt-4o".to_string()),
9828 fast_model: Some("gpt-4o-mini".to_string()),
9829 vision_model: None,
9830 reasoning_effort: None,
9831 responses_only_models: vec![],
9832 request_overrides: None,
9833 extra: Default::default(),
9834 api_key_from_env: false,
9835 });
9836 config.features.provider_model_ref = true;
9837 config.defaults = Some(DefaultsConfig {
9838 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9839 fast: Some(bamboo_domain::ProviderModelRef::new(
9840 "openai",
9841 "gpt-4o-mini",
9842 )),
9843 task_summary: None,
9844 vision: None,
9845 memory_background: Some(bamboo_domain::ProviderModelRef::new(
9846 "anthropic",
9847 "claude-3-5-haiku",
9848 )),
9849 planning: None,
9850 search: None,
9851 code_review: None,
9852 sub_agent: None,
9853 subagent_models: Default::default(),
9854 });
9855
9856 assert_eq!(
9857 config.get_memory_background_model(),
9858 Some("claude-3-5-haiku".to_string())
9859 );
9860 }
9861
9862 #[test]
9863 #[allow(clippy::field_reassign_with_default)]
9865 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
9866 let mut config = Config::default();
9867 config.provider = "openai".to_string();
9868 config.features.provider_model_ref = true;
9869 config.defaults = Some(DefaultsConfig {
9870 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9871 fast: Some(bamboo_domain::ProviderModelRef::new(
9872 "anthropic",
9873 "claude-3-5-haiku",
9874 )),
9875 task_summary: None,
9876 vision: None,
9877 memory_background: None,
9878 planning: None,
9879 search: None,
9880 code_review: None,
9881 sub_agent: None,
9882 subagent_models: Default::default(),
9883 });
9884
9885 assert_eq!(
9886 config.get_memory_background_model(),
9887 Some("claude-3-5-haiku".to_string())
9888 );
9889 }
9890
9891 #[test]
9892 #[allow(clippy::field_reassign_with_default)]
9894 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
9895 let mut config = Config::default();
9896 config.provider = "openai".to_string();
9897 config.providers.openai = Some(OpenAIConfig {
9898 api_key: "test".to_string(),
9899 api_key_encrypted: None,
9900 credential_ref: None,
9901 base_url: None,
9902 model: Some("gpt-4o".to_string()),
9903 fast_model: Some("legacy-gpt-4o-mini".to_string()),
9904 vision_model: None,
9905 reasoning_effort: None,
9906 responses_only_models: vec![],
9907 request_overrides: None,
9908 extra: Default::default(),
9909 api_key_from_env: false,
9910 });
9911 config.features.provider_model_ref = false;
9912 config.defaults = Some(DefaultsConfig {
9913 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
9914 fast: Some(bamboo_domain::ProviderModelRef::new(
9915 "anthropic",
9916 "claude-3-5-haiku",
9917 )),
9918 task_summary: None,
9919 vision: None,
9920 memory_background: Some(bamboo_domain::ProviderModelRef::new(
9921 "anthropic",
9922 "claude-3-5-haiku",
9923 )),
9924 planning: None,
9925 search: None,
9926 code_review: None,
9927 sub_agent: None,
9928 subagent_models: Default::default(),
9929 });
9930
9931 assert_eq!(
9932 config.get_memory_background_model(),
9933 Some("legacy-gpt-4o-mini".to_string())
9934 );
9935 }
9936
9937 #[test]
9944 fn is_host_trusted_requires_https_scheme() {
9945 let hosts = vec!["github.com/bigduu/".to_string()];
9946 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
9947 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9948 }
9949
9950 #[test]
9951 fn is_host_trusted_is_case_insensitive_on_both_sides() {
9952 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
9954 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9955 let hosts = vec!["github.com/bigduu/".to_string()];
9957 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
9958 }
9959
9960 #[test]
9961 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
9962 let hosts = vec!["trusted.example.com".to_string()];
9963 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
9964 assert!(!is_host_trusted(
9968 "https://trusted.example.com.evil.com/x",
9969 &hosts
9970 ));
9971 assert!(!is_host_trusted(
9972 "https://trusted.example.comevil.com/x",
9973 &hosts
9974 ));
9975 }
9976
9977 #[test]
9978 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
9979 let hosts = vec!["github.com/bigduu/".to_string()];
9981 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
9982 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
9983 }
9984
9985 #[test]
9986 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
9987 let hosts = vec!["example.com".to_string()];
9988 assert!(is_host_trusted("https://example.com/", &hosts));
9989 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
9990 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
9993 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
9994 }
9995
9996 #[test]
9997 fn is_host_trusted_uses_the_real_host_not_userinfo() {
9998 let hosts = vec!["github.com/bigduu/".to_string()];
9999 assert!(is_host_trusted(
10001 "https://someuser@github.com/bigduu/x",
10002 &hosts
10003 ));
10004 assert!(!is_host_trusted(
10007 "https://github.com@evil.com/bigduu/",
10008 &hosts
10009 ));
10010 }
10011
10012 #[test]
10013 fn is_host_trusted_ignores_an_explicit_port() {
10014 let hosts = vec!["github.com/bigduu/".to_string()];
10015 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10016 }
10017
10018 #[test]
10019 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10020 let hosts = vec!["github.com/bigduu/".to_string()];
10021 assert!(!is_host_trusted("not a url at all", &hosts));
10022 assert!(!is_host_trusted("", &hosts));
10023 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10025
10026 #[test]
10027 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10028 let hosts = vec!["github.com/bigduu/".to_string()];
10029 assert!(!is_host_trusted(
10032 "https://github.com/bigduu/../evil/x",
10033 &hosts
10034 ));
10035 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10037 }
10038
10039 #[test]
10040 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10041 let mut config = Config::default();
10042 config.plugin_trust.trusted_hosts = vec![
10043 " GitHub.com/BigDuu/ ".to_string(),
10044 "".to_string(),
10045 " ".to_string(),
10046 "Example.COM".to_string(),
10047 ];
10048 config.normalize_plugin_trust_settings();
10049 assert_eq!(
10050 config.plugin_trust.trusted_hosts,
10051 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10052 );
10053 }
10054
10055 #[test]
10061 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10062 let config = Config::default();
10064 assert_eq!(
10065 config.plugin_trust.enforcement,
10066 PluginTrustEnforcement::Strict
10067 );
10068 assert!(!config.plugin_trust.enforcement_is_off());
10069
10070 let json = serde_json::json!({
10076 "trusted_hosts": ["example.com"],
10077 "trusted_keys": [],
10078 });
10079 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10080 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10081 }
10082
10083 #[test]
10084 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10085 for raw in ["off", "OFF", "Off", " off "] {
10086 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10087 "enforcement": raw,
10088 }))
10089 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10090 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10091 assert!(trust.enforcement_is_off());
10092 }
10093 for raw in ["strict", "STRICT", " Strict "] {
10094 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10095 "enforcement": raw,
10096 }))
10097 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10098 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10099 }
10100
10101 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10102 "enforcement": "nonsense",
10103 }))
10104 .expect_err("an unrecognized string must be rejected, not silently default");
10105 assert!(err.to_string().contains("nonsense"));
10106 }
10107
10108 #[test]
10109 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10110 let trust: PluginTrustConfig =
10113 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10114 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10115
10116 let trust: PluginTrustConfig =
10117 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10118 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10119 }
10120
10121 #[test]
10122 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10123 let trust = PluginTrustConfig {
10128 enforcement: PluginTrustEnforcement::Off,
10129 ..PluginTrustConfig::default()
10130 };
10131 let json = serde_json::to_value(&trust).unwrap();
10132 assert_eq!(json["enforcement"], "off");
10133
10134 let trust = PluginTrustConfig {
10135 enforcement: PluginTrustEnforcement::Strict,
10136 ..PluginTrustConfig::default()
10137 };
10138 let json = serde_json::to_value(&trust).unwrap();
10139 assert_eq!(json["enforcement"], "strict");
10140 }
10141
10142 #[test]
10143 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10144 let mut config = Config::default();
10147 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10148 config.normalize_plugin_trust_settings();
10149 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10150 }
10151
10152 #[test]
10153 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10154 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10159 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10160 )));
10161 assert_eq!(
10162 config.plugin_trust.enforcement,
10163 PluginTrustEnforcement::Strict
10164 );
10165
10166 let outcome = crate::dot_path::apply_dot_path_set(
10167 &config,
10168 "plugin_trust.enforcement",
10169 crate::dot_path::parse_cli_value("off"),
10170 )
10171 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10172 assert_eq!(
10173 outcome.config.plugin_trust.enforcement,
10174 PluginTrustEnforcement::Off
10175 );
10176
10177 let outcome = crate::dot_path::apply_dot_path_set(
10179 &outcome.config,
10180 "plugin_trust.enforcement",
10181 crate::dot_path::parse_cli_value("strict"),
10182 )
10183 .expect("setting it back to strict should also round-trip");
10184 assert_eq!(
10185 outcome.config.plugin_trust.enforcement,
10186 PluginTrustEnforcement::Strict
10187 );
10188 }
10189}