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