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)]
576pub struct RunBudgetConfig {
577 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub max_total_tokens: Option<u64>,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub max_tool_calls: Option<u32>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub max_subagents: Option<u32>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub max_rounds: Option<u32>,
600}
601
602fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
605 match (config_default, request) {
606 (Some(a), Some(b)) => Some(a.min(b)),
607 (Some(a), None) => Some(a),
608 (None, Some(b)) => Some(b),
609 (None, None) => None,
610 }
611}
612
613impl RunBudgetConfig {
614 pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
629 let Some(over) = request_override else {
630 return *self;
631 };
632 Self {
633 max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
634 max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
635 max_subagents: min_limit(self.max_subagents, over.max_subagents),
636 max_rounds: min_limit(self.max_rounds, over.max_rounds),
637 }
638 }
639}
640
641#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
647#[serde(rename_all = "snake_case")]
648pub enum CodexAuthMode {
649 Inherit,
651 ApiKey,
653 Custom,
655 #[default]
657 Bamboo,
658}
659
660#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(rename_all = "snake_case")]
663pub enum CodexWireApi {
664 #[default]
665 Responses,
666}
667
668#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
670#[serde(rename_all = "snake_case")]
671pub enum CodexMode {
672 #[default]
674 Exec,
675 AppServer,
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "kebab-case")]
682pub enum CodexSandbox {
683 ReadOnly,
684 WorkspaceWrite,
685 DangerFullAccess,
686}
687
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(rename_all = "kebab-case")]
692pub enum CodexApprovalPolicy {
693 Never,
694 OnFailure,
695 OnRequest,
696}
697
698#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
709pub struct SubagentsConfig {
710 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub max_concurrent: Option<usize>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub worker_bin: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub worker_args: Option<Vec<String>>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub fabric_dir: Option<String>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub executor: Option<String>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub claude_code_binary: Option<String>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub claude_code_model: Option<String>,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub claude_code_permission_mode: Option<String>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub claude_code_inherit_user_config: Option<bool>,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub claude_code_forward_env: Option<Vec<String>>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub codex_binary: Option<String>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub codex_model: Option<String>,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub codex_mode: Option<CodexMode>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub codex_auth_mode: Option<CodexAuthMode>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub codex_base_url: Option<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub codex_wire_api: Option<CodexWireApi>,
778 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub codex_provider_key_ref: Option<crate::CredentialRef>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub codex_forward_env: Option<Vec<String>>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub codex_sandbox: Option<CodexSandbox>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub codex_approval_policy: Option<CodexApprovalPolicy>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub codex_network_access: Option<bool>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub codex_allow_danger_bypass: Option<bool>,
802 #[serde(skip)]
809 pub broker: Option<BrokerClientConfig>,
810 #[serde(default, skip_serializing_if = "Vec::is_empty")]
816 pub remote_placements: Vec<RemoteActorPlacement>,
817 #[serde(default, skip_serializing_if = "Vec::is_empty")]
830 pub schedulable_placements: Vec<SchedulablePlacement>,
831 #[serde(default, skip_serializing_if = "Vec::is_empty")]
850 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
851}
852
853#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
856pub struct McpRoleAllowlistEntry {
857 pub role: String,
865 #[serde(default)]
873 pub tools: Vec<String>,
874}
875
876#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(deny_unknown_fields)]
883pub struct OperatorPlacementRequirements {
884 pub trust_zone: String,
885 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub workspace_label: Option<String>,
887 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub network_zone: Option<String>,
889 #[serde(default)]
890 pub require_network_isolation: bool,
891 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
894 pub required_tools: std::collections::BTreeSet<String>,
895}
896
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(deny_unknown_fields)]
901pub struct SchedulableBrokerParent {
902 pub parent_mailbox: String,
903 pub parent_role: String,
904}
905
906#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
910pub struct SchedulablePlacement {
911 pub role: String,
914 pub pool: String,
916 #[serde(default, skip_serializing_if = "String::is_empty")]
918 pub registry_url: String,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub token_env: Option<String>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub ca_cert_file: Option<String>,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub broker_parent: Option<SchedulableBrokerParent>,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub placement_requirements: Option<OperatorPlacementRequirements>,
934}
935
936#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
947pub struct RemoteActorPlacement {
948 pub role: String,
951 pub endpoint: String,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub token_env: Option<String>,
958 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub ca_cert_file: Option<String>,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub broker_peer: Option<RemoteBrokerPeer>,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub placement_requirements: Option<OperatorPlacementRequirements>,
968}
969
970#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
972#[serde(deny_unknown_fields)]
973pub struct RemoteBrokerPeer {
974 pub parent_mailbox: String,
975 pub worker_mailbox: String,
976 pub parent_role: Option<String>,
977 pub worker_role: Option<String>,
978}
979impl RemoteBrokerPeer {
980 pub fn valid(&self) -> bool {
981 fn identifier(s: &str, mailbox: bool) -> bool {
982 !s.is_empty()
983 && s.len() <= 256
984 && s != "."
985 && s != ".."
986 && s.bytes()
987 .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
988 && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
989 }
990 identifier(&self.parent_mailbox, true)
991 && identifier(&self.worker_mailbox, true)
992 && self.parent_mailbox != self.worker_mailbox
993 && self
994 .parent_role
995 .as_deref()
996 .is_none_or(|s| identifier(s, false))
997 && self
998 .worker_role
999 .as_deref()
1000 .is_some_and(|s| identifier(s, false))
1001 }
1002}
1003
1004#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1006pub struct BrokerClientConfig {
1007 pub endpoint: String,
1009 #[serde(default, skip_serializing)]
1015 pub token: String,
1016 #[serde(default, skip_serializing)]
1018 pub token_encrypted: Option<String>,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub credential_ref: Option<crate::CredentialRef>,
1022 #[serde(default)]
1025 pub configured: bool,
1026}
1027
1028impl std::fmt::Debug for BrokerClientConfig {
1029 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030 formatter
1031 .debug_struct("BrokerClientConfig")
1032 .field("endpoint", &self.endpoint)
1033 .field("credential_ref", &self.credential_ref)
1034 .field("configured", &self.configured)
1035 .finish_non_exhaustive()
1036 }
1037}
1038
1039#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1041pub struct DesktopChannelConfig {
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub enabled: Option<bool>,
1050}
1051
1052#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1054pub struct NtfyChannelConfig {
1055 #[serde(default)]
1056 pub enabled: bool,
1057 #[serde(default = "default_ntfy_base_url")]
1059 pub base_url: String,
1060 #[serde(default)]
1063 pub topic: String,
1064 #[serde(default, skip_serializing)]
1070 pub token: Option<String>,
1071 #[serde(default, skip_serializing)]
1073 pub token_encrypted: Option<String>,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub credential_ref: Option<crate::CredentialRef>,
1077 #[serde(default)]
1079 pub configured: bool,
1080}
1081
1082impl Default for NtfyChannelConfig {
1083 fn default() -> Self {
1084 Self {
1085 enabled: false,
1086 base_url: default_ntfy_base_url(),
1087 topic: String::new(),
1088 token: None,
1089 token_encrypted: None,
1090 credential_ref: None,
1091 configured: false,
1092 }
1093 }
1094}
1095
1096fn default_ntfy_base_url() -> String {
1097 "https://ntfy.sh".to_string()
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1102pub struct BarkChannelConfig {
1103 #[serde(default)]
1104 pub enabled: bool,
1105 #[serde(default = "default_bark_base_url")]
1107 pub base_url: String,
1108 #[serde(default, skip_serializing)]
1113 pub device_key: Option<String>,
1114 #[serde(default, skip_serializing)]
1116 pub device_key_encrypted: Option<String>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub credential_ref: Option<crate::CredentialRef>,
1120 #[serde(default)]
1122 pub configured: bool,
1123}
1124
1125impl Default for BarkChannelConfig {
1126 fn default() -> Self {
1127 Self {
1128 enabled: false,
1129 base_url: default_bark_base_url(),
1130 device_key: None,
1131 device_key_encrypted: None,
1132 credential_ref: None,
1133 configured: false,
1134 }
1135 }
1136}
1137
1138fn default_bark_base_url() -> String {
1139 "https://api.day.app".to_string()
1140}
1141
1142#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1147pub struct NotificationsConfig {
1148 #[serde(default)]
1149 pub desktop: DesktopChannelConfig,
1150 #[serde(default)]
1151 pub ntfy: NtfyChannelConfig,
1152 #[serde(default)]
1153 pub bark: BarkChannelConfig,
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1160pub struct ConnectPlatformConfig {
1161 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub id: Option<String>,
1176 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub project_id: Option<bamboo_domain::ProjectId>,
1181 #[serde(rename = "type")]
1185 pub platform_type: String,
1186 #[serde(default, skip_serializing)]
1192 pub token: Option<String>,
1193 #[serde(default, skip_serializing_if = "Option::is_none")]
1195 pub token_encrypted: Option<String>,
1196 #[serde(default, skip_serializing_if = "Option::is_none")]
1198 pub token_credential_ref: Option<crate::CredentialRef>,
1199 #[serde(default)]
1201 pub token_configured: bool,
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub app_id: Option<String>,
1206 #[serde(default, skip_serializing)]
1212 pub app_secret: Option<String>,
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1215 pub app_secret_encrypted: Option<String>,
1216 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub app_secret_credential_ref: Option<crate::CredentialRef>,
1219 #[serde(default)]
1221 pub app_secret_configured: bool,
1222 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub domain: Option<String>,
1229 #[serde(default)]
1234 pub allow_from: Vec<String>,
1235 #[serde(default)]
1239 pub admin_from: Vec<String>,
1240}
1241
1242#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1246pub struct ConnectConfig {
1247 #[serde(default)]
1248 pub platforms: Vec<ConnectPlatformConfig>,
1249}
1250
1251fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1252 config.platforms.is_empty()
1253}
1254
1255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1262pub struct TrustedKey {
1263 pub label: String,
1265 pub algorithm: String,
1267 pub public_key: String,
1269}
1270
1271fn default_trusted_keys() -> Vec<TrustedKey> {
1278 vec![
1279 TrustedKey {
1280 label: "nova (bigduu official)".to_string(),
1281 algorithm: "ed25519".to_string(),
1282 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1283 .to_string(),
1284 },
1285 TrustedKey {
1286 label: "magpie (bigduu official)".to_string(),
1287 algorithm: "ed25519".to_string(),
1288 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1289 .to_string(),
1290 },
1291 ]
1292}
1293
1294fn default_trusted_hosts() -> Vec<String> {
1297 vec!["github.com/bigduu/".to_string()]
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1317pub struct PluginTrustConfig {
1318 #[serde(default = "default_trusted_hosts")]
1329 pub trusted_hosts: Vec<String>,
1330 #[serde(default = "default_trusted_keys")]
1333 pub trusted_keys: Vec<TrustedKey>,
1334 #[serde(default)]
1348 pub enforcement: PluginTrustEnforcement,
1349}
1350
1351impl Default for PluginTrustConfig {
1352 fn default() -> Self {
1353 Self {
1354 trusted_hosts: default_trusted_hosts(),
1355 trusted_keys: default_trusted_keys(),
1356 enforcement: PluginTrustEnforcement::default(),
1357 }
1358 }
1359}
1360
1361impl PluginTrustConfig {
1362 pub fn is_host_trusted(&self, url: &str) -> bool {
1368 is_host_trusted(url, &self.trusted_hosts)
1369 }
1370
1371 pub fn enforcement_is_off(&self) -> bool {
1377 matches!(self.enforcement, PluginTrustEnforcement::Off)
1378 }
1379}
1380
1381#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1394#[serde(rename_all = "snake_case")]
1395pub enum PluginTrustEnforcement {
1396 #[default]
1401 Strict,
1402 Off,
1406}
1407
1408impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1409 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1410 where
1411 D: serde::Deserializer<'de>,
1412 {
1413 #[derive(Deserialize)]
1414 #[serde(untagged)]
1415 enum Repr {
1416 Bool(bool),
1417 Str(String),
1418 }
1419 match Repr::deserialize(deserializer)? {
1420 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1421 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1422 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1423 "strict" => Ok(PluginTrustEnforcement::Strict),
1424 "off" => Ok(PluginTrustEnforcement::Off),
1425 other => Err(serde::de::Error::custom(format!(
1426 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1427 \"off\""
1428 ))),
1429 },
1430 }
1431 }
1432}
1433
1434struct TrustedHostEntry<'a> {
1439 host: &'a str,
1440 path_prefix: &'a str,
1441}
1442
1443fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1447 match entry.find('/') {
1448 Some(index) => TrustedHostEntry {
1449 host: &entry[..index],
1450 path_prefix: &entry[index..],
1451 },
1452 None => TrustedHostEntry {
1453 host: entry,
1454 path_prefix: "",
1455 },
1456 }
1457}
1458
1459fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1471 if prefix.is_empty() || path == prefix {
1472 return true;
1473 }
1474 if prefix.ends_with('/') {
1475 return path.starts_with(prefix);
1476 }
1477 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1478}
1479
1480pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1504 let Ok(parsed) = url::Url::parse(url) else {
1505 return false;
1506 };
1507 if parsed.scheme() != "https" {
1508 return false;
1509 }
1510 let Some(host) = parsed.host_str() else {
1511 return false;
1512 };
1513 let host = host.to_ascii_lowercase();
1514 let path = parsed.path();
1515
1516 trusted_hosts.iter().any(|raw_entry| {
1517 let entry = raw_entry.trim().to_ascii_lowercase();
1518 let parsed_entry = parse_trusted_host_entry(&entry);
1519 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1520 })
1521}
1522
1523#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1530#[serde(rename_all = "snake_case")]
1531pub enum ContextManagementStrategy {
1532 #[default]
1533 Summary,
1534 RetrievalWindow,
1535}
1536
1537#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1540#[serde(rename_all = "snake_case")]
1541pub enum ContextManagementFallbackStrategy {
1542 #[default]
1543 None,
1544 Summary,
1545}
1546
1547fn default_retrieval_window_min_recent_user_turns() -> usize {
1548 3
1549}
1550
1551fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1552 0.80
1553}
1554
1555fn default_retrieval_window_target_usage_ratio() -> f64 {
1556 0.60
1557}
1558
1559fn default_history_tool_required() -> bool {
1560 true
1561}
1562
1563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1565#[serde(default)]
1566pub struct RetrievalWindowContextConfig {
1567 #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1568 pub min_recent_user_turns: usize,
1569 #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1570 pub trigger_usage_ratio: f64,
1571 #[serde(default = "default_retrieval_window_target_usage_ratio")]
1572 pub target_usage_ratio: f64,
1573 #[serde(default = "default_history_tool_required")]
1574 pub history_tool_required: bool,
1575 #[serde(default)]
1576 pub fallback_strategy: ContextManagementFallbackStrategy,
1577}
1578
1579impl Default for RetrievalWindowContextConfig {
1580 fn default() -> Self {
1581 Self {
1582 min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1583 trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1584 target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1585 history_tool_required: default_history_tool_required(),
1586 fallback_strategy: ContextManagementFallbackStrategy::None,
1587 }
1588 }
1589}
1590
1591#[derive(Debug, Clone, Serialize, PartialEq)]
1593pub struct ContextManagementConfig {
1594 pub strategy: ContextManagementStrategy,
1595 pub retrieval_window: RetrievalWindowContextConfig,
1596}
1597
1598impl Default for ContextManagementConfig {
1599 fn default() -> Self {
1600 Self {
1601 strategy: ContextManagementStrategy::Summary,
1602 retrieval_window: RetrievalWindowContextConfig::default(),
1603 }
1604 }
1605}
1606
1607impl ContextManagementConfig {
1608 pub fn is_default(&self) -> bool {
1609 *self == Self::default()
1610 }
1611
1612 pub fn validate(&self) -> std::result::Result<(), String> {
1613 let policy = &self.retrieval_window;
1614 if policy.min_recent_user_turns == 0 {
1615 return Err(
1616 "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1617 .to_string(),
1618 );
1619 }
1620 if !policy.target_usage_ratio.is_finite()
1621 || !policy.trigger_usage_ratio.is_finite()
1622 || policy.target_usage_ratio < 0.01
1623 || policy.target_usage_ratio >= policy.trigger_usage_ratio
1624 || policy.trigger_usage_ratio > 1.0
1625 {
1626 return Err(
1627 "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1628 .to_string(),
1629 );
1630 }
1631 if self.strategy == ContextManagementStrategy::RetrievalWindow
1632 && !policy.history_tool_required
1633 {
1634 return Err(
1635 "retrieval_window requires history_tool_required=true in this release".to_string(),
1636 );
1637 }
1638 Ok(())
1639 }
1640
1641 pub fn retrieval_target_usage_percent(&self) -> u8 {
1646 ((self.retrieval_window.target_usage_ratio * 100.0).floor() as u8).max(1)
1647 }
1648}
1649
1650impl<'de> Deserialize<'de> for ContextManagementConfig {
1651 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1652 where
1653 D: serde::Deserializer<'de>,
1654 {
1655 #[derive(Deserialize)]
1656 #[serde(default)]
1657 struct Wire {
1658 strategy: ContextManagementStrategy,
1659 retrieval_window: RetrievalWindowContextConfig,
1660 }
1661
1662 impl Default for Wire {
1663 fn default() -> Self {
1664 let defaults = ContextManagementConfig::default();
1665 Self {
1666 strategy: defaults.strategy,
1667 retrieval_window: defaults.retrieval_window,
1668 }
1669 }
1670 }
1671
1672 let wire = Wire::deserialize(deserializer)?;
1673 let config = Self {
1674 strategy: wire.strategy,
1675 retrieval_window: wire.retrieval_window,
1676 };
1677 config.validate().map_err(serde::de::Error::custom)?;
1678 Ok(config)
1679 }
1680}
1681
1682#[derive(Debug, Clone, Serialize, Deserialize)]
1687#[doc(hidden)]
1688pub struct ConfigValues {
1689 #[serde(default)]
1691 pub http_proxy: String,
1692 #[serde(default)]
1694 pub https_proxy: String,
1695 #[serde(skip_serializing)]
1699 pub proxy_auth: Option<ProxyAuth>,
1700 #[serde(default, skip_serializing_if = "Option::is_none")]
1702 pub proxy_auth_encrypted: Option<String>,
1703 #[serde(default, skip_serializing_if = "Option::is_none")]
1705 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1706 #[serde(default)]
1708 pub headless_auth: bool,
1709
1710 #[serde(default = "default_provider")]
1712 pub provider: String,
1713
1714 #[serde(default, skip_serializing_if = "Option::is_none")]
1716 pub defaults: Option<DefaultsConfig>,
1717
1718 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1725 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1726
1727 #[serde(default, skip_serializing_if = "Option::is_none")]
1731 pub default_provider_instance: Option<String>,
1732
1733 #[serde(default)]
1735 pub server: ServerConfig,
1736
1737 #[serde(default)]
1741 pub keyword_masking: KeywordMaskingConfig,
1742
1743 #[serde(default)]
1747 pub anthropic_model_mapping: AnthropicModelMapping,
1748
1749 #[serde(default)]
1753 pub gemini_model_mapping: GeminiModelMapping,
1754
1755 #[serde(default)]
1760 pub hooks: HooksConfig,
1761
1762 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1768 pub lifecycle_hooks: LifecycleHooksConfig,
1769
1770 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1774 pub tools: ToolsConfig,
1775
1776 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1781 pub skills: SkillsConfig,
1782
1783 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1787 pub env_vars: Vec<EnvVarEntry>,
1788
1789 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 pub default_work_area: Option<DefaultWorkAreaConfig>,
1792
1793 #[serde(default, skip_serializing_if = "Option::is_none")]
1795 pub access_control: Option<AccessControlConfig>,
1796
1797 #[serde(default)]
1799 pub features: FeatureFlags,
1800
1801 #[serde(default)]
1806 pub run_budget: RunBudgetConfig,
1807
1808 #[serde(default)]
1810 pub stream_timeout: StreamTimeoutConfig,
1811
1812 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1814 pub context_management: ContextManagementConfig,
1815
1816 #[serde(
1820 default,
1821 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1822 )]
1823 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1824
1825 #[serde(default, rename = "mcpServers", alias = "mcp")]
1831 pub mcp: bamboo_domain::mcp_config::McpConfig,
1832
1833 #[serde(default)]
1837 pub notifications: NotificationsConfig,
1838
1839 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1844 pub connect: ConnectConfig,
1845
1846 #[serde(default)]
1850 pub plugin_trust: PluginTrustConfig,
1851
1852 #[serde(default, flatten)]
1858 pub extra: BTreeMap<String, Value>,
1859}
1860
1861impl Default for ConfigValues {
1862 fn default() -> Self {
1863 Self {
1864 http_proxy: String::new(),
1865 https_proxy: String::new(),
1866 proxy_auth: None,
1867 proxy_auth_encrypted: None,
1868 proxy_auth_credential_ref: None,
1869 headless_auth: false,
1870 run_budget: RunBudgetConfig::default(),
1871 stream_timeout: StreamTimeoutConfig::default(),
1872 context_management: ContextManagementConfig::default(),
1873 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1874 provider: default_provider(),
1875 provider_instances: HashMap::new(),
1876 default_provider_instance: None,
1877 server: ServerConfig::default(),
1878 keyword_masking: KeywordMaskingConfig::default(),
1879 anthropic_model_mapping: AnthropicModelMapping::default(),
1880 gemini_model_mapping: GeminiModelMapping::default(),
1881 hooks: HooksConfig::default(),
1882 lifecycle_hooks: LifecycleHooksConfig::default(),
1883 tools: ToolsConfig::default(),
1884 skills: SkillsConfig::default(),
1885 env_vars: Vec::new(),
1886 default_work_area: None,
1887 access_control: None,
1888 features: FeatureFlags::default(),
1889 defaults: None,
1890 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1891 notifications: NotificationsConfig::default(),
1892 connect: ConnectConfig::default(),
1893 plugin_trust: PluginTrustConfig::default(),
1894 extra: BTreeMap::new(),
1895 }
1896 }
1897}
1898
1899#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1902struct NetworkConfigSection {
1903 #[serde(default)]
1904 http_proxy: String,
1905 #[serde(default)]
1906 https_proxy: String,
1907 #[serde(skip_serializing)]
1908 proxy_auth: Option<ProxyAuth>,
1909 #[serde(default, skip_serializing_if = "Option::is_none")]
1910 proxy_auth_encrypted: Option<String>,
1911 #[serde(default, skip_serializing_if = "Option::is_none")]
1912 proxy_auth_credential_ref: Option<crate::CredentialRef>,
1913 #[serde(default)]
1914 headless_auth: bool,
1915 #[serde(default)]
1916 server: ServerConfig,
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1920struct ProviderRoutingConfigSection {
1921 #[serde(default = "default_provider")]
1922 provider: String,
1923 #[serde(default, skip_serializing_if = "Option::is_none")]
1924 defaults: Option<DefaultsConfig>,
1925 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1926 provider_instances: HashMap<String, ProviderInstanceConfig>,
1927 #[serde(default, skip_serializing_if = "Option::is_none")]
1928 default_provider_instance: Option<String>,
1929}
1930
1931impl Default for ProviderRoutingConfigSection {
1932 fn default() -> Self {
1933 Self {
1934 provider: default_provider(),
1935 defaults: None,
1936 provider_instances: HashMap::new(),
1937 default_provider_instance: None,
1938 }
1939 }
1940}
1941
1942#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1943struct ModelBehaviorConfigSection {
1944 #[serde(default)]
1945 keyword_masking: KeywordMaskingConfig,
1946 #[serde(default)]
1947 anthropic_model_mapping: AnthropicModelMapping,
1948 #[serde(default)]
1949 gemini_model_mapping: GeminiModelMapping,
1950 #[serde(default)]
1951 hooks: HooksConfig,
1952 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1953 lifecycle_hooks: LifecycleHooksConfig,
1954}
1955
1956#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1957struct ToolingConfigSection {
1958 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1959 tools: ToolsConfig,
1960 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1961 skills: SkillsConfig,
1962 #[serde(default, rename = "mcpServers", alias = "mcp")]
1963 mcp: bamboo_domain::mcp_config::McpConfig,
1964}
1965
1966#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1967struct WorkspaceConfigSection {
1968 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1969 env_vars: Vec<EnvVarEntry>,
1970 #[serde(default, skip_serializing_if = "Option::is_none")]
1971 default_work_area: Option<DefaultWorkAreaConfig>,
1972 #[serde(default, skip_serializing_if = "Option::is_none")]
1973 access_control: Option<AccessControlConfig>,
1974}
1975
1976#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1977struct ExecutionConfigSection {
1978 #[serde(default)]
1979 features: FeatureFlags,
1980 #[serde(default)]
1981 run_budget: RunBudgetConfig,
1982 #[serde(default)]
1983 stream_timeout: StreamTimeoutConfig,
1984 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1985 context_management: ContextManagementConfig,
1986 #[serde(
1987 default,
1988 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1989 )]
1990 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1991}
1992
1993#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1994struct IntegrationConfigSection {
1995 #[serde(default)]
1996 notifications: NotificationsConfig,
1997 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1998 connect: ConnectConfig,
1999}
2000
2001#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2002struct PluginSecurityConfigSection {
2003 #[serde(default)]
2004 plugin_trust: PluginTrustConfig,
2005}
2006
2007macro_rules! count_fields {
2010 ($($field:ident),* $(,)?) => {
2011 <[()]>::len(&[$(count_fields!(@one $field)),*])
2012 };
2013 (@one $field:ident) => { () };
2014}
2015
2016macro_rules! define_counted_struct {
2017 (
2018 $(#[$struct_meta:meta])*
2019 $visibility:vis struct $name:ident {
2020 $(
2021 $(#[$field_meta:meta])*
2022 $field_visibility:vis $field:ident: $field_type:ty
2023 ),* $(,)?
2024 }
2025 count $count_visibility:vis $count_name:ident;
2026 ) => {
2027 $(#[$struct_meta])*
2028 $visibility struct $name {
2029 $(
2030 $(#[$field_meta])*
2031 $field_visibility $field: $field_type,
2032 )*
2033 }
2034
2035 $count_visibility const $count_name: usize = count_fields!($($field),*);
2036 };
2037}
2038
2039define_counted_struct! {
2040 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2046 struct ConfigRoot {
2047 #[serde(flatten)]
2048 network: NetworkConfigSection,
2049 #[serde(flatten)]
2050 provider_routing: ProviderRoutingConfigSection,
2051 #[serde(flatten)]
2052 model_behavior: ModelBehaviorConfigSection,
2053 #[serde(flatten)]
2054 tooling: ToolingConfigSection,
2055 #[serde(flatten)]
2056 workspace: WorkspaceConfigSection,
2057 #[serde(flatten)]
2058 execution: ExecutionConfigSection,
2059 #[serde(flatten)]
2060 integrations: IntegrationConfigSection,
2061 #[serde(flatten)]
2062 plugin_security: PluginSecurityConfigSection,
2063 #[serde(default, flatten)]
2064 extra: BTreeMap<String, Value>,
2065 }
2066 count pub PERSISTED_ROOT_FIELD_COUNT;
2067}
2068
2069impl From<ConfigValues> for ConfigRoot {
2070 fn from(values: ConfigValues) -> Self {
2071 let ConfigValues {
2074 http_proxy,
2075 https_proxy,
2076 proxy_auth,
2077 proxy_auth_encrypted,
2078 proxy_auth_credential_ref,
2079 headless_auth,
2080 provider,
2081 defaults,
2082 provider_instances,
2083 default_provider_instance,
2084 server,
2085 keyword_masking,
2086 anthropic_model_mapping,
2087 gemini_model_mapping,
2088 hooks,
2089 lifecycle_hooks,
2090 tools,
2091 skills,
2092 env_vars,
2093 default_work_area,
2094 access_control,
2095 features,
2096 run_budget,
2097 stream_timeout,
2098 context_management,
2099 cluster_fabric,
2100 mcp,
2101 notifications,
2102 connect,
2103 plugin_trust,
2104 extra,
2105 } = values;
2106
2107 Self {
2108 network: NetworkConfigSection {
2109 http_proxy,
2110 https_proxy,
2111 proxy_auth,
2112 proxy_auth_encrypted,
2113 proxy_auth_credential_ref,
2114 headless_auth,
2115 server,
2116 },
2117 provider_routing: ProviderRoutingConfigSection {
2118 provider,
2119 defaults,
2120 provider_instances,
2121 default_provider_instance,
2122 },
2123 model_behavior: ModelBehaviorConfigSection {
2124 keyword_masking,
2125 anthropic_model_mapping,
2126 gemini_model_mapping,
2127 hooks,
2128 lifecycle_hooks,
2129 },
2130 tooling: ToolingConfigSection { tools, skills, mcp },
2131 workspace: WorkspaceConfigSection {
2132 env_vars,
2133 default_work_area,
2134 access_control,
2135 },
2136 execution: ExecutionConfigSection {
2137 features,
2138 run_budget,
2139 stream_timeout,
2140 context_management,
2141 cluster_fabric,
2142 },
2143 integrations: IntegrationConfigSection {
2144 notifications,
2145 connect,
2146 },
2147 plugin_security: PluginSecurityConfigSection { plugin_trust },
2148 extra,
2149 }
2150 }
2151}
2152
2153impl From<ConfigRoot> for ConfigValues {
2154 fn from(root: ConfigRoot) -> Self {
2155 let ConfigRoot {
2158 network,
2159 provider_routing,
2160 model_behavior,
2161 tooling,
2162 workspace,
2163 execution,
2164 integrations,
2165 plugin_security,
2166 extra,
2167 } = root;
2168 let NetworkConfigSection {
2169 http_proxy,
2170 https_proxy,
2171 proxy_auth,
2172 proxy_auth_encrypted,
2173 proxy_auth_credential_ref,
2174 headless_auth,
2175 server,
2176 } = network;
2177 let ProviderRoutingConfigSection {
2178 provider,
2179 defaults,
2180 provider_instances,
2181 default_provider_instance,
2182 } = provider_routing;
2183 let ModelBehaviorConfigSection {
2184 keyword_masking,
2185 anthropic_model_mapping,
2186 gemini_model_mapping,
2187 hooks,
2188 lifecycle_hooks,
2189 } = model_behavior;
2190 let ToolingConfigSection { tools, skills, mcp } = tooling;
2191 let WorkspaceConfigSection {
2192 env_vars,
2193 default_work_area,
2194 access_control,
2195 } = workspace;
2196 let ExecutionConfigSection {
2197 features,
2198 run_budget,
2199 stream_timeout,
2200 context_management,
2201 cluster_fabric,
2202 } = execution;
2203 let IntegrationConfigSection {
2204 notifications,
2205 connect,
2206 } = integrations;
2207 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2208
2209 Self {
2210 http_proxy,
2211 https_proxy,
2212 proxy_auth,
2213 proxy_auth_encrypted,
2214 proxy_auth_credential_ref,
2215 headless_auth,
2216 provider,
2217 defaults,
2218 provider_instances,
2219 default_provider_instance,
2220 server,
2221 keyword_masking,
2222 anthropic_model_mapping,
2223 gemini_model_mapping,
2224 hooks,
2225 lifecycle_hooks,
2226 tools,
2227 skills,
2228 env_vars,
2229 default_work_area,
2230 access_control,
2231 features,
2232 run_budget,
2233 stream_timeout,
2234 context_management,
2235 cluster_fabric,
2236 mcp,
2237 notifications,
2238 connect,
2239 plugin_trust,
2240 extra,
2241 }
2242 }
2243}
2244
2245fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2252 let instance_native = values
2253 .default_provider_instance
2254 .as_ref()
2255 .is_some_and(|id| values.provider_instances.contains_key(id));
2256 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2257 if instance_native {
2258 if let Some(object) = value.as_object_mut() {
2259 object.remove("provider");
2260 object.remove("providers");
2261 }
2262 }
2263 Ok(value)
2264}
2265
2266define_counted_struct! {
2267 #[derive(Debug, Clone)]
2270 pub struct Config {
2271 values: ConfigValues,
2272 pub(crate) memory: crate::MemoryConfigModule,
2273 pub(crate) subagents: crate::SubagentsConfigModule,
2274 pub(crate) providers: crate::ProviderConfigsModule,
2275 recovery_status: Option<ConfigRecoveryStatus>,
2276 }
2277 count pub CONFIG_FIELD_COUNT;
2278}
2279
2280pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2282const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2283const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2284
2285impl std::ops::Deref for Config {
2286 type Target = ConfigValues;
2287
2288 fn deref(&self) -> &Self::Target {
2289 &self.values
2290 }
2291}
2292
2293impl std::ops::DerefMut for Config {
2294 fn deref_mut(&mut self) -> &mut Self::Target {
2295 &mut self.values
2296 }
2297}
2298
2299impl Serialize for Config {
2300 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2301 where
2302 S: serde::Serializer,
2303 {
2304 self.to_compatibility_value()
2305 .map_err(serde::ser::Error::custom)?
2306 .serialize(serializer)
2307 }
2308}
2309
2310impl<'de> Deserialize<'de> for Config {
2311 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2312 where
2313 D: serde::Deserializer<'de>,
2314 {
2315 use serde::de::Error;
2316
2317 let mut value = Value::deserialize(deserializer)?;
2318 let object = value
2319 .as_object_mut()
2320 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2321 let memory = object
2322 .remove("memory")
2323 .map(serde_json::from_value)
2324 .transpose()
2325 .map_err(D::Error::custom)?
2326 .unwrap_or_default();
2327 let subagents = object
2328 .remove("subagents")
2329 .map(serde_json::from_value)
2330 .transpose()
2331 .map_err(D::Error::custom)?
2332 .unwrap_or_default();
2333 let providers = object
2334 .remove("providers")
2335 .map(serde_json::from_value)
2336 .transpose()
2337 .map_err(D::Error::custom)?
2338 .unwrap_or_default();
2339 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2340
2341 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2342 }
2343}
2344
2345#[derive(Debug, Clone, PartialEq, Serialize)]
2347#[serde(tag = "kind", rename_all = "snake_case")]
2348pub enum ConfigRecoverySource {
2349 Salvaged { fields: Vec<String> },
2354 Backup { generation: usize },
2357 Defaults,
2359}
2360
2361#[derive(Debug, Clone, PartialEq, Serialize)]
2373pub struct ConfigRecoveryStatus {
2374 pub source: ConfigRecoverySource,
2376 pub quarantine_path: Option<PathBuf>,
2382 pub confirmed: bool,
2385}
2386
2387#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2391pub struct ProviderConfigs {
2392 #[serde(skip_serializing_if = "Option::is_none")]
2394 pub openai: Option<OpenAIConfig>,
2395 #[serde(skip_serializing_if = "Option::is_none")]
2397 pub anthropic: Option<AnthropicConfig>,
2398 #[serde(skip_serializing_if = "Option::is_none")]
2400 pub gemini: Option<GeminiConfig>,
2401 #[serde(skip_serializing_if = "Option::is_none")]
2403 pub copilot: Option<CopilotConfig>,
2404 #[serde(skip_serializing_if = "Option::is_none")]
2406 pub bodhi: Option<BodhiConfig>,
2407
2408 #[serde(default, flatten)]
2410 pub extra: BTreeMap<String, Value>,
2411}
2412
2413impl ProviderConfigs {
2414 pub fn clear_legacy_builtin_aliases(&mut self) {
2417 self.openai = None;
2418 self.anthropic = None;
2419 self.gemini = None;
2420 self.copilot = None;
2421 self.bodhi = None;
2422 self.extra.remove("provider");
2423 }
2424}
2425
2426#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2428pub struct FeatureFlags {
2429 #[serde(default)]
2431 pub ticket_mutation: bool,
2432 #[serde(default)]
2434 pub ticket_dispatch: bool,
2435 #[serde(default)]
2437 pub provider_model_ref: bool,
2438 #[serde(default)]
2440 pub dynamic_model_routing: bool,
2441}
2442
2443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2447pub struct DefaultsConfig {
2448 pub chat: bamboo_domain::ProviderModelRef,
2449 #[serde(default, skip_serializing_if = "Option::is_none")]
2450 pub fast: Option<bamboo_domain::ProviderModelRef>,
2451 #[serde(default, skip_serializing_if = "Option::is_none")]
2452 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2453 #[serde(default, skip_serializing_if = "Option::is_none")]
2454 pub vision: Option<bamboo_domain::ProviderModelRef>,
2455 #[serde(default, skip_serializing_if = "Option::is_none")]
2456 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2457 #[serde(default, skip_serializing_if = "Option::is_none")]
2460 pub planning: Option<bamboo_domain::ProviderModelRef>,
2461 #[serde(default, skip_serializing_if = "Option::is_none")]
2464 pub search: Option<bamboo_domain::ProviderModelRef>,
2465 #[serde(default, skip_serializing_if = "Option::is_none")]
2468 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2469 #[serde(
2472 default,
2473 skip_serializing_if = "Option::is_none",
2474 alias = "sub_session"
2475 )]
2476 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2477 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2481 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2482}
2483
2484#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2486pub struct HooksConfig {
2487 #[serde(default)]
2489 pub image_fallback: ImageFallbackHookConfig,
2490}
2491
2492pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2494pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2496pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2500
2501pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2503 "SessionStart",
2504 "UserPromptSubmit",
2505 "PreToolUse",
2506 "PostToolUse",
2507 "Stop",
2508 "SessionEnd",
2509 "PreCompact",
2510 "Notification",
2511];
2512
2513fn default_lifecycle_hook_timeout_ms() -> u64 {
2514 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2515}
2516
2517fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2518 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2519}
2520
2521fn lifecycle_hook_enabled_default() -> bool {
2522 true
2523}
2524
2525fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2526 *value
2527}
2528
2529#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2534pub struct LifecycleHooksConfig {
2535 #[serde(default)]
2536 pub enabled: bool,
2537 #[serde(
2538 default,
2539 rename = "SessionStart",
2540 skip_serializing_if = "Vec::is_empty"
2541 )]
2542 pub session_start: Vec<LifecycleHookGroup>,
2543 #[serde(
2544 default,
2545 rename = "UserPromptSubmit",
2546 skip_serializing_if = "Vec::is_empty"
2547 )]
2548 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2549 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2550 pub pre_tool_use: Vec<LifecycleHookGroup>,
2551 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2552 pub post_tool_use: Vec<LifecycleHookGroup>,
2553 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2554 pub stop: Vec<LifecycleHookGroup>,
2555 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2556 pub session_end: Vec<LifecycleHookGroup>,
2557 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2558 pub pre_compact: Vec<LifecycleHookGroup>,
2559 #[serde(
2560 default,
2561 rename = "Notification",
2562 skip_serializing_if = "Vec::is_empty"
2563 )]
2564 pub notification: Vec<LifecycleHookGroup>,
2565}
2566
2567impl LifecycleHooksConfig {
2568 pub fn is_empty(&self) -> bool {
2569 !self.enabled
2570 && self.session_start.is_empty()
2571 && self.user_prompt_submit.is_empty()
2572 && self.pre_tool_use.is_empty()
2573 && self.post_tool_use.is_empty()
2574 && self.stop.is_empty()
2575 && self.session_end.is_empty()
2576 && self.pre_compact.is_empty()
2577 && self.notification.is_empty()
2578 }
2579}
2580
2581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2583pub struct LifecycleHookGroup {
2584 #[serde(
2587 default = "lifecycle_hook_enabled_default",
2588 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2589 )]
2590 pub enabled: bool,
2591 #[serde(default, skip_serializing_if = "Option::is_none")]
2592 pub matcher: Option<String>,
2593 #[serde(default)]
2594 pub hooks: Vec<LifecycleHookHandler>,
2595}
2596
2597impl Default for LifecycleHookGroup {
2598 fn default() -> Self {
2599 Self {
2600 enabled: true,
2601 matcher: None,
2602 hooks: Vec::new(),
2603 }
2604 }
2605}
2606
2607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2612#[serde(tag = "type", rename_all = "lowercase")]
2613pub enum LifecycleHookHandler {
2614 Command {
2615 command: String,
2616 #[serde(
2617 default = "default_lifecycle_hook_timeout_ms",
2618 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2619 )]
2620 timeout_ms: u64,
2621 },
2622 Script {
2623 path: String,
2624 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2625 runner: LifecycleScriptRunner,
2626 #[serde(
2627 default = "default_lifecycle_hook_timeout_ms",
2628 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2629 )]
2630 timeout_ms: u64,
2631 },
2632}
2633
2634#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2640#[serde(rename_all = "lowercase")]
2641pub enum LifecycleScriptRunner {
2642 #[default]
2643 Auto,
2644 Node,
2645 Bun,
2646 Python,
2647 Bash,
2648 PowerShell,
2649 Cmd,
2650}
2651
2652impl LifecycleScriptRunner {
2653 pub fn is_auto(&self) -> bool {
2654 matches!(self, Self::Auto)
2655 }
2656
2657 pub fn as_str(self) -> &'static str {
2658 match self {
2659 Self::Auto => "auto",
2660 Self::Node => "node",
2661 Self::Bun => "bun",
2662 Self::Python => "python",
2663 Self::Bash => "bash",
2664 Self::PowerShell => "powershell",
2665 Self::Cmd => "cmd",
2666 }
2667 }
2668
2669 pub fn supports_path(self, path: &str) -> bool {
2671 let extension = lifecycle_script_extension(path);
2672 match self {
2673 Self::Auto => extension.is_some(),
2674 Self::Node | Self::Bun => {
2675 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2676 }
2677 Self::Python => matches!(extension.as_deref(), Some("py")),
2678 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2679 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2680 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2681 }
2682 }
2683}
2684
2685pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2688 let extension = std::path::Path::new(path)
2689 .extension()?
2690 .to_str()?
2691 .to_ascii_lowercase();
2692 matches!(
2693 extension.as_str(),
2694 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2695 )
2696 .then_some(extension)
2697}
2698
2699impl LifecycleHookHandler {
2700 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2701 Self::Command {
2702 command: command.into(),
2703 timeout_ms,
2704 }
2705 }
2706
2707 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2708 Self::Script {
2709 path: path.into(),
2710 runner,
2711 timeout_ms,
2712 }
2713 }
2714
2715 pub fn timeout_ms(&self) -> u64 {
2716 match self {
2717 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2718 }
2719 }
2720}
2721
2722#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2729pub struct RequestOverridesConfig {
2730 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2732 pub common: RequestScopeOverride,
2733 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2735 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2736 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2738 pub rules: Vec<ModelRequestRule>,
2739}
2740
2741#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2743pub struct ModelRequestRule {
2744 pub model_pattern: String,
2746 #[serde(default, skip_serializing_if = "Option::is_none")]
2748 pub endpoint: Option<String>,
2749 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2751 pub scope: RequestScopeOverride,
2752}
2753
2754#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2756pub struct RequestScopeOverride {
2757 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2759 pub headers: BTreeMap<String, TemplateExpr>,
2760 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2762 pub body_patch: Vec<BodyPatch>,
2763}
2764
2765impl RequestScopeOverride {
2766 pub fn is_empty(&self) -> bool {
2767 self.headers.is_empty() && self.body_patch.is_empty()
2768 }
2769}
2770
2771#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2773pub struct BodyPatch {
2774 pub path: String,
2776 #[serde(default)]
2778 pub op: BodyPatchOp,
2779 #[serde(default, skip_serializing_if = "Option::is_none")]
2781 pub value: Option<PatchValue>,
2782}
2783
2784#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2786#[serde(rename_all = "snake_case")]
2787pub enum BodyPatchOp {
2788 #[default]
2789 Set,
2790 Remove,
2791}
2792
2793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2795#[serde(untagged)]
2796pub enum PatchValue {
2797 Template(TemplateExpr),
2798 Json(Value),
2799}
2800
2801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2803#[serde(untagged)]
2804pub enum TemplateExpr {
2805 Literal(String),
2807 Structured(TemplateExprSpec),
2809}
2810
2811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2813#[serde(tag = "type", rename_all = "snake_case")]
2814pub enum TemplateExprSpec {
2815 Literal { value: String },
2817 EnvRef {
2819 name: String,
2820 #[serde(default, skip_serializing_if = "Option::is_none")]
2821 fallback: Option<String>,
2822 },
2823 Generated { generator: GeneratedValue },
2825 Format { template: String },
2827}
2828
2829#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2831#[serde(rename_all = "snake_case")]
2832pub enum GeneratedValue {
2833 Uuid,
2834 UnixMs,
2835}
2836
2837#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2839pub struct ToolsConfig {
2840 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2842 pub disabled: Vec<String>,
2843
2844 #[serde(default, flatten)]
2846 pub extra: BTreeMap<String, Value>,
2847}
2848
2849impl ToolsConfig {
2850 fn is_empty(&self) -> bool {
2851 self.disabled.is_empty() && self.extra.is_empty()
2852 }
2853}
2854
2855#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2857pub struct SkillsConfig {
2858 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2860 pub disabled: Vec<String>,
2861
2862 #[serde(default, flatten)]
2864 pub extra: BTreeMap<String, Value>,
2865}
2866
2867impl SkillsConfig {
2868 fn is_empty(&self) -> bool {
2869 self.disabled.is_empty() && self.extra.is_empty()
2870 }
2871}
2872
2873#[derive(Debug, Clone, Serialize, Deserialize)]
2878pub struct ImageFallbackHookConfig {
2879 #[serde(default = "default_true_hooks")]
2880 pub enabled: bool,
2881
2882 #[serde(default = "default_image_fallback_mode")]
2884 pub mode: String,
2885}
2886
2887impl Default for ImageFallbackHookConfig {
2888 fn default() -> Self {
2889 Self {
2890 enabled: default_true_hooks(),
2891 mode: default_image_fallback_mode(),
2892 }
2893 }
2894}
2895
2896fn default_image_fallback_mode() -> String {
2897 "placeholder".to_string()
2898}
2899
2900fn default_true_hooks() -> bool {
2901 false
2904}
2905
2906pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2918
2919#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2920pub struct OpenAIConfig {
2921 #[serde(default, skip_serializing)]
2925 pub api_key: String,
2926 #[serde(default, skip_serializing_if = "Option::is_none")]
2928 pub api_key_encrypted: Option<String>,
2929 #[serde(default, skip_serializing_if = "Option::is_none")]
2931 pub credential_ref: Option<crate::CredentialRef>,
2932 #[serde(skip)]
2937 pub api_key_from_env: bool,
2938 #[serde(skip_serializing_if = "Option::is_none")]
2940 pub base_url: Option<String>,
2941 #[serde(skip_serializing_if = "Option::is_none")]
2943 pub model: Option<String>,
2944 #[serde(default, skip_serializing_if = "Option::is_none")]
2947 pub fast_model: Option<String>,
2948 #[serde(default, skip_serializing_if = "Option::is_none")]
2951 pub vision_model: Option<String>,
2952 #[serde(skip_serializing_if = "Option::is_none")]
2954 pub reasoning_effort: Option<ReasoningEffort>,
2955
2956 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2963 pub responses_only_models: Vec<String>,
2964 #[serde(default, skip_serializing_if = "Option::is_none")]
2966 pub request_overrides: Option<RequestOverridesConfig>,
2967
2968 #[serde(default, flatten)]
2970 pub extra: BTreeMap<String, Value>,
2971}
2972
2973impl OpenAIConfig {
2974 pub fn explicit_prompt_cache_enabled(&self) -> bool {
2981 self.extra
2982 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
2983 .and_then(Value::as_bool)
2984 .unwrap_or(true)
2985 }
2986}
2987
2988#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3000pub struct AnthropicConfig {
3001 #[serde(default, skip_serializing)]
3005 pub api_key: String,
3006 #[serde(default, skip_serializing_if = "Option::is_none")]
3008 pub api_key_encrypted: Option<String>,
3009 #[serde(default, skip_serializing_if = "Option::is_none")]
3011 pub credential_ref: Option<crate::CredentialRef>,
3012 #[serde(skip)]
3017 pub api_key_from_env: bool,
3018 #[serde(skip_serializing_if = "Option::is_none")]
3020 pub base_url: Option<String>,
3021 #[serde(skip_serializing_if = "Option::is_none")]
3023 pub model: Option<String>,
3024 #[serde(default, skip_serializing_if = "Option::is_none")]
3027 pub fast_model: Option<String>,
3028 #[serde(default, skip_serializing_if = "Option::is_none")]
3031 pub vision_model: Option<String>,
3032 #[serde(skip_serializing_if = "Option::is_none")]
3034 pub max_tokens: Option<u32>,
3035 #[serde(skip_serializing_if = "Option::is_none")]
3037 pub reasoning_effort: Option<ReasoningEffort>,
3038 #[serde(default, skip_serializing_if = "Option::is_none")]
3040 pub request_overrides: Option<RequestOverridesConfig>,
3041
3042 #[serde(default, skip_serializing_if = "Option::is_none")]
3059 pub thinking_replay_always: Option<bool>,
3060
3061 #[serde(default, flatten)]
3063 pub extra: BTreeMap<String, Value>,
3064}
3065
3066#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3077pub struct GeminiConfig {
3078 #[serde(default, skip_serializing)]
3082 pub api_key: String,
3083 #[serde(default, skip_serializing_if = "Option::is_none")]
3085 pub api_key_encrypted: Option<String>,
3086 #[serde(default, skip_serializing_if = "Option::is_none")]
3088 pub credential_ref: Option<crate::CredentialRef>,
3089 #[serde(skip)]
3094 pub api_key_from_env: bool,
3095 #[serde(skip_serializing_if = "Option::is_none")]
3097 pub base_url: Option<String>,
3098 #[serde(skip_serializing_if = "Option::is_none")]
3100 pub model: Option<String>,
3101 #[serde(default, skip_serializing_if = "Option::is_none")]
3104 pub fast_model: Option<String>,
3105 #[serde(default, skip_serializing_if = "Option::is_none")]
3108 pub vision_model: Option<String>,
3109 #[serde(skip_serializing_if = "Option::is_none")]
3111 pub reasoning_effort: Option<ReasoningEffort>,
3112 #[serde(default, skip_serializing_if = "Option::is_none")]
3114 pub request_overrides: Option<RequestOverridesConfig>,
3115
3116 #[serde(default, flatten)]
3118 pub extra: BTreeMap<String, Value>,
3119}
3120
3121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3133pub struct CopilotConfig {
3134 #[serde(default)]
3136 pub enabled: bool,
3137 #[serde(default)]
3139 pub headless_auth: bool,
3140 #[serde(skip_serializing_if = "Option::is_none")]
3142 pub model: Option<String>,
3143 #[serde(default, skip_serializing_if = "Option::is_none")]
3146 pub fast_model: Option<String>,
3147 #[serde(default, skip_serializing_if = "Option::is_none")]
3150 pub vision_model: Option<String>,
3151 #[serde(skip_serializing_if = "Option::is_none")]
3153 pub reasoning_effort: Option<ReasoningEffort>,
3154
3155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3164 pub responses_only_models: Vec<String>,
3165 #[serde(default, skip_serializing_if = "Option::is_none")]
3167 pub request_overrides: Option<RequestOverridesConfig>,
3168
3169 #[serde(default, flatten)]
3171 pub extra: BTreeMap<String, Value>,
3172}
3173
3174#[derive(Debug, Clone, Serialize, Deserialize)]
3179pub struct BodhiConfig {
3180 #[serde(default, skip_serializing)]
3182 pub api_key: String,
3183 #[serde(default, skip_serializing_if = "Option::is_none")]
3185 pub api_key_encrypted: Option<String>,
3186 #[serde(default, skip_serializing_if = "Option::is_none")]
3188 pub credential_ref: Option<crate::CredentialRef>,
3189 #[serde(skip_serializing_if = "Option::is_none")]
3191 pub base_url: Option<String>,
3192 #[serde(skip_serializing_if = "Option::is_none")]
3194 pub target_provider: Option<String>,
3195 #[serde(skip_serializing_if = "Option::is_none")]
3197 pub reasoning_effort: Option<ReasoningEffort>,
3198
3199 #[serde(default, flatten)]
3201 pub extra: BTreeMap<String, Value>,
3202}
3203
3204fn default_provider() -> String {
3206 "anthropic".to_string()
3207}
3208
3209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3241pub struct ProviderInstanceConfig {
3242 pub provider_type: String,
3247
3248 #[serde(default, skip_serializing_if = "Option::is_none")]
3250 pub label: Option<String>,
3251
3252 #[serde(default, skip_serializing)]
3254 pub api_key: String,
3255
3256 #[serde(default, skip_serializing_if = "Option::is_none")]
3259 pub api_key_encrypted: Option<String>,
3260
3261 #[serde(default, skip_serializing_if = "Option::is_none")]
3263 pub credential_ref: Option<crate::CredentialRef>,
3264
3265 #[serde(default, skip_serializing_if = "Option::is_none")]
3267 pub base_url: Option<String>,
3268
3269 #[serde(default, skip_serializing_if = "Option::is_none")]
3271 pub model: Option<String>,
3272
3273 #[serde(default, skip_serializing_if = "Option::is_none")]
3275 pub fast_model: Option<String>,
3276
3277 #[serde(default, skip_serializing_if = "Option::is_none")]
3279 pub vision_model: Option<String>,
3280
3281 #[serde(default, skip_serializing_if = "Option::is_none")]
3283 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3284
3285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3287 pub responses_only_models: Vec<String>,
3288
3289 #[serde(default, skip_serializing_if = "Option::is_none")]
3291 pub request_overrides: Option<RequestOverridesConfig>,
3292
3293 #[serde(default = "default_true")]
3296 pub enabled: bool,
3297
3298 #[serde(default, flatten)]
3300 pub extra: BTreeMap<String, Value>,
3301}
3302
3303fn default_true() -> bool {
3304 true
3305}
3306
3307fn default_port() -> u16 {
3309 9562
3310}
3311
3312fn default_bind() -> String {
3314 "127.0.0.1".to_string()
3315}
3316
3317fn default_workers() -> usize {
3319 10
3320}
3321
3322fn default_data_dir() -> PathBuf {
3324 super::paths::bamboo_dir()
3325}
3326
3327#[derive(Debug, Clone, Serialize, Deserialize)]
3329pub struct ServerConfig {
3330 #[serde(default = "default_port")]
3332 pub port: u16,
3333
3334 #[serde(default = "default_bind")]
3336 pub bind: String,
3337
3338 pub static_dir: Option<PathBuf>,
3340
3341 #[serde(default = "default_workers")]
3343 pub workers: usize,
3344
3345 #[serde(default, skip_serializing_if = "Option::is_none")]
3353 pub tls: Option<TlsConfig>,
3354
3355 #[serde(default, flatten)]
3357 pub extra: BTreeMap<String, Value>,
3358}
3359
3360impl Default for ServerConfig {
3361 fn default() -> Self {
3362 Self {
3363 port: default_port(),
3364 bind: default_bind(),
3365 static_dir: None,
3366 workers: default_workers(),
3367 tls: None,
3368 extra: BTreeMap::new(),
3369 }
3370 }
3371}
3372
3373#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3379pub struct TlsConfig {
3380 pub cert_file: PathBuf,
3382 pub key_file: PathBuf,
3384}
3385
3386#[derive(Debug, Clone, Serialize, Deserialize)]
3388pub struct ProxyAuth {
3389 pub username: String,
3391 pub password: String,
3393}
3394
3395fn parse_bool_env(value: &str) -> bool {
3399 matches!(
3400 value.trim().to_ascii_lowercase().as_str(),
3401 "1" | "true" | "yes" | "y" | "on"
3402 )
3403}
3404
3405fn expand_user_path(value: &str) -> PathBuf {
3406 let trimmed = value.trim();
3407 if let Some(rest) = trimmed.strip_prefix("~/") {
3408 if let Some(home) = dirs::home_dir() {
3409 return home.join(rest);
3410 }
3411 }
3412 PathBuf::from(trimmed)
3413}
3414
3415impl Default for Config {
3416 fn default() -> Self {
3417 Self::create_default()
3426 }
3427}
3428
3429#[derive(Debug, Clone, PartialEq, Eq)]
3431pub struct PromptSafeEnvVarEntry {
3432 pub name: String,
3433 pub secret: bool,
3434 pub description: Option<String>,
3435}
3436
3437static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3441
3442static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3443
3444fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3445 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3446}
3447
3448fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3449 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3450}
3451
3452impl Config {
3453 fn from_parts(
3454 values: ConfigValues,
3455 memory: Option<MemoryConfig>,
3456 subagents: SubagentsConfig,
3457 providers: ProviderConfigs,
3458 ) -> Self {
3459 Self {
3460 values,
3461 memory: crate::MemoryConfigModule(memory),
3462 subagents: crate::SubagentsConfigModule(subagents),
3463 providers: crate::ProviderConfigsModule(providers),
3464 recovery_status: None,
3465 }
3466 }
3467
3468 pub(crate) fn section_values(&self) -> ConfigValues {
3474 self.values.clone()
3475 }
3476
3477 pub(crate) fn from_section_parts(
3478 values: ConfigValues,
3479 memory: Option<MemoryConfig>,
3480 subagents: SubagentsConfig,
3481 providers: ProviderConfigs,
3482 ) -> Self {
3483 Self::from_parts(values, memory, subagents, providers)
3484 }
3485
3486 pub fn memory(&self) -> &Option<MemoryConfig> {
3488 &self.memory.0
3489 }
3490
3491 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3492 &mut self.memory.0
3493 }
3494
3495 pub fn subagents(&self) -> &SubagentsConfig {
3497 &self.subagents.0
3498 }
3499
3500 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3501 &mut self.subagents.0
3502 }
3503
3504 pub fn providers(&self) -> &ProviderConfigs {
3506 &self.providers.0
3507 }
3508
3509 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3510 &mut self.providers.0
3511 }
3512
3513 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3517 if self
3518 .default_provider_instance
3519 .as_ref()
3520 .is_some_and(|id| self.provider_instances.contains_key(id))
3521 {
3522 self.providers.0.clear_legacy_builtin_aliases();
3523 }
3524 }
3525
3526 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3532 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3533 let object = value
3534 .as_object_mut()
3535 .expect("ConfigRoot always serializes as a JSON object");
3536 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3537 object.insert(
3538 "subagents".to_string(),
3539 serde_json::to_value(self.subagents())?,
3540 );
3541 object.insert(
3542 "providers".to_string(),
3543 serde_json::to_value(self.providers())?,
3544 );
3545 Ok(value)
3546 }
3547
3548 pub fn new() -> Self {
3567 Self::from_data_dir(None)
3568 }
3569
3570 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3571 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3572 Ok(facade) => facade.effective_config(),
3573 Err(error) => {
3574 tracing::warn!(
3575 error = %error,
3576 "completed modular configuration is unavailable; refusing legacy-root fallback"
3577 );
3578 Self::create_default()
3579 }
3580 };
3581
3582 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3583 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3584 config.proxy_auth = None;
3585 }
3586 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3587 tracing::warn!(error = %error, "provider credential hydration unavailable");
3588 }
3589 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3590 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3591 }
3592 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3593 tracing::warn!(error = %error, "env credential hydration unavailable");
3594 for entry in &mut config.env_vars {
3595 if entry.secret {
3596 entry.value.clear();
3597 }
3598 }
3599 }
3600 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3601 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3602 config.clear_cluster_runtime_credentials();
3603 }
3604 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3605 tracing::warn!(error = %error, "notification credential hydration unavailable");
3606 config.notifications.ntfy.token = None;
3607 config.notifications.bark.device_key = None;
3608 }
3609 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3610 tracing::warn!(error = %error, "connect credential hydration unavailable");
3611 for platform in &mut config.connect.platforms {
3612 platform.token = None;
3613 platform.app_secret = None;
3614 }
3615 }
3616 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3617 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3618 config.clear_access_control_runtime_verifiers();
3619 }
3620 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3621 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3622 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3623 broker.token.clear();
3624 }
3625 }
3626 config.normalize_tool_settings();
3627 config.normalize_skill_settings();
3628 config.normalize_plugin_trust_settings();
3629 config.extra.remove("data_dir");
3630 if apply_env {
3631 config.apply_env_overrides();
3632 }
3633 if publish {
3634 config.publish_env_vars();
3635 }
3636 config
3637 }
3638
3639 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3648 let data_dir = data_dir
3650 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3651 .unwrap_or_else(default_data_dir);
3652
3653 match crate::modular_authority_boundary_present(&data_dir) {
3654 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3655 Ok(false) => {}
3656 Err(error) => {
3657 tracing::warn!(
3658 error = %error,
3659 "modular configuration marker is unavailable; refusing legacy-root fallback"
3660 );
3661 let mut config = Self::create_default();
3662 if apply_env {
3663 config.apply_env_overrides();
3664 }
3665 if publish {
3666 config.publish_env_vars();
3667 }
3668 return config;
3669 }
3670 }
3671
3672 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3677 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3678 .map_err(|error| {
3679 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3680 error
3681 })
3682 .is_ok();
3683 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3684 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3685 .map_err(|error| {
3686 tracing::warn!(error = %error, "cluster credential migration unavailable");
3687 error
3688 })
3689 .is_ok();
3690
3691 match crate::modular_authority_boundary_present(&data_dir) {
3696 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3697 Ok(false) => {}
3698 Err(error) => {
3699 tracing::warn!(
3700 error = %error,
3701 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3702 );
3703 let mut config = Self::create_default();
3704 if apply_env {
3705 config.apply_env_overrides();
3706 }
3707 if publish {
3708 config.publish_env_vars();
3709 }
3710 return config;
3711 }
3712 }
3713
3714 let config_path = data_dir.join("config.json");
3715
3716 let mut config = if config_path.exists() {
3717 if let Ok(content) = std::fs::read_to_string(&config_path) {
3718 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3719 tracing::warn!(
3729 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3730 e
3731 );
3732 let quarantine_path = quarantine_corrupt_config(&config_path);
3733 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3734 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3735 .or_else(|| {
3736 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3737 (cfg, ConfigRecoverySource::Backup { generation })
3738 })
3739 })
3740 .unwrap_or_else(|| {
3741 tracing::warn!(
3742 "Could not salvage and no usable config.json.bak; using defaults"
3743 );
3744 (Self::create_default(), ConfigRecoverySource::Defaults)
3745 });
3746 recovered.recovery_status = Some(ConfigRecoveryStatus {
3747 source,
3748 quarantine_path,
3749 confirmed: false,
3750 });
3751 recovered
3752 })
3753 } else {
3754 Self::create_default()
3755 }
3756 } else {
3757 Self::create_default()
3758 };
3759
3760 let mut memory_module = config.memory.clone();
3766 match memory_module.load_sync(&data_dir) {
3767 Ok(true) => config.memory = memory_module,
3768 Ok(false) => {}
3769 Err(error) => tracing::warn!(
3770 "Failed to load memory.json; using legacy config.json memory: {error}"
3771 ),
3772 }
3773 let mut subagents_module = config.subagents.clone();
3774 match subagents_module.load_sync(&data_dir) {
3775 Ok(true) => config.subagents = subagents_module,
3776 Ok(false) => {}
3777 Err(error) => tracing::warn!(
3778 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3779 ),
3780 }
3781 if provider_mcp_ready {
3782 let mut providers_module = config.providers.clone();
3783 match providers_module.load_sync(&data_dir) {
3784 Ok(true) => config.providers = providers_module,
3785 Ok(false) => {}
3786 Err(error) => tracing::warn!(
3787 "Failed to load providers.json; using legacy config.json providers: {error}"
3788 ),
3789 }
3790 }
3791
3792 config.hydrate_proxy_auth_from_encrypted();
3794 if provider_mcp_ready {
3795 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3796 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3797 config.proxy_auth = None;
3798 }
3799 }
3800 config.hydrate_provider_api_keys_from_encrypted();
3802 config.hydrate_provider_instance_api_keys_from_encrypted();
3804 config.hydrate_mcp_secrets_from_encrypted();
3806 if provider_mcp_ready {
3807 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3808 tracing::warn!(error = %error, "provider credential hydration unavailable");
3809 }
3810 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3811 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3812 }
3813 }
3814 config.hydrate_env_vars_from_encrypted();
3816 if provider_mcp_ready {
3817 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3818 tracing::warn!(error = %error, "env credential hydration unavailable");
3819 for entry in &mut config.env_vars {
3820 if entry.secret {
3821 entry.value.clear();
3822 }
3823 }
3824 }
3825 }
3826 if cluster_ready {
3831 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3832 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3833 config.clear_cluster_runtime_credentials();
3834 }
3835 } else {
3836 config.clear_cluster_runtime_credentials();
3837 }
3838 config.hydrate_broker_token_from_encrypted();
3840 config.hydrate_notifications_from_encrypted();
3842 if provider_mcp_ready {
3843 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3844 tracing::warn!(error = %error, "notification credential hydration unavailable");
3845 config.notifications.ntfy.token = None;
3846 config.notifications.bark.device_key = None;
3847 }
3848 } else {
3849 config.notifications.ntfy.token = None;
3854 config.notifications.bark.device_key = None;
3855 }
3856 config.merge_connect_config(&data_dir);
3861 scrub_legacy_connect_from_config_backups(&data_dir);
3871 config.hydrate_connect_platform_tokens_from_encrypted();
3873 if provider_mcp_ready {
3874 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3875 tracing::warn!(error = %error, "connect credential hydration unavailable");
3876 for platform in &mut config.connect.platforms {
3877 platform.token = None;
3878 platform.app_secret = None;
3879 }
3880 }
3881 }
3882 if provider_mcp_ready {
3883 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3884 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3885 config.clear_access_control_runtime_verifiers();
3886 }
3887 } else {
3888 config.clear_access_control_runtime_verifiers();
3889 }
3890 config.normalize_tool_settings();
3891 config.normalize_skill_settings();
3892 config.normalize_plugin_trust_settings();
3893
3894 config.extra.remove("data_dir");
3897
3898 if apply_env {
3902 config.apply_env_overrides();
3903 }
3904
3905 if publish {
3909 config.publish_env_vars();
3910 }
3911
3912 config
3913 }
3914
3915 fn apply_env_overrides(&mut self) {
3919 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3920 if let Ok(port) = port.parse() {
3921 self.server.port = port;
3922 }
3923 }
3924
3925 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3926 self.server.bind = bind;
3927 }
3928
3929 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3934 let provider = provider.trim().to_string();
3935 if !provider.is_empty() {
3936 self.provider = provider.clone();
3937 if !self.provider_instances.is_empty() {
3938 let selected = self
3939 .provider_instances
3940 .contains_key(&provider)
3941 .then_some(provider.clone())
3942 .or_else(|| {
3943 let mut matching = self
3944 .provider_instances
3945 .iter()
3946 .filter(|(_, instance)| {
3947 instance.enabled && instance.provider_type == provider
3948 })
3949 .map(|(id, _)| id.clone())
3950 .collect::<Vec<_>>();
3951 matching.sort();
3952 matching.into_iter().next()
3953 });
3954 self.default_provider_instance = selected;
3955 }
3956 }
3957 }
3958
3959 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
3960 self.headless_auth = parse_bool_env(&headless);
3961 }
3962
3963 if let Ok(project_prompt_injection) =
3964 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
3965 {
3966 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3967 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
3968 }
3969
3970 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
3971 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3972 memory.relevant_recall = parse_bool_env(&relevant_recall);
3973 }
3974
3975 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
3976 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3977 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
3978 }
3979
3980 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
3981 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3982 memory.project_first_dream = parse_bool_env(&project_first_dream);
3983 }
3984
3985 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
3992 let key = key.trim();
3993 if !key.is_empty() {
3994 if self.provider_instances.is_empty() {
3995 let openai = self
3996 .providers
3997 .openai
3998 .get_or_insert_with(OpenAIConfig::default);
3999 openai.api_key = key.to_string();
4000 openai.api_key_from_env = true;
4001 } else {
4002 self.apply_provider_instance_env_key("openai", key);
4003 }
4004 }
4005 }
4006 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4007 let key = key.trim();
4008 if !key.is_empty() {
4009 if self.provider_instances.is_empty() {
4010 let anthropic = self
4011 .providers
4012 .anthropic
4013 .get_or_insert_with(AnthropicConfig::default);
4014 anthropic.api_key = key.to_string();
4015 anthropic.api_key_from_env = true;
4016 } else {
4017 self.apply_provider_instance_env_key("anthropic", key);
4018 }
4019 }
4020 }
4021 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4022 let key = key.trim();
4023 if !key.is_empty() {
4024 if self.provider_instances.is_empty() {
4025 let gemini = self
4026 .providers
4027 .gemini
4028 .get_or_insert_with(GeminiConfig::default);
4029 gemini.api_key = key.to_string();
4030 gemini.api_key_from_env = true;
4031 } else {
4032 self.apply_provider_instance_env_key("gemini", key);
4033 }
4034 }
4035 }
4036 }
4037
4038 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4039 for instance in self.provider_instances.values_mut().filter(|instance| {
4040 instance.provider_type == provider_type
4041 && crate::provider_instance_api_key_from_env(instance)
4042 }) {
4043 instance.api_key = key.to_string();
4044 instance.api_key_encrypted = None;
4045 }
4046 }
4047
4048 pub fn apply_runtime_env_overrides(&mut self) {
4053 self.apply_env_overrides();
4054 }
4055
4056 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4062 Self::from_data_dir_impl(data_dir, true, true)
4063 }
4064
4065 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4069 Self::from_data_dir_impl(data_dir, false, true)
4070 }
4071
4072 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4079 Self::from_data_dir_impl(data_dir, false, false)
4080 }
4081
4082 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4110 let connect_path = data_dir.join("connect.json");
4111 match std::fs::read_to_string(&connect_path) {
4112 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4113 Ok(connect) => {
4114 let legacy_key_present = !connect_config_is_empty(&self.connect);
4115 if legacy_key_present {
4116 tracing::warn!(
4117 "config.json still has a legacy `connect` key alongside \
4118 connect.json; connect.json takes precedence — dropping the \
4119 stale key from config.json now"
4120 );
4121 }
4122 self.connect = connect;
4123 if legacy_key_present {
4124 strip_legacy_connect_key_from_config_json(data_dir);
4125 }
4126 }
4127 Err(e) => {
4128 tracing::error!(
4129 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4130 connect config instead of falling back to a legacy config.json copy",
4131 connect_path,
4132 e
4133 );
4134 quarantine_corrupt_connect(&connect_path);
4135 self.connect = ConnectConfig::default();
4136 }
4137 },
4138 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4139 if !connect_config_is_empty(&self.connect) {
4140 tracing::info!(
4141 "Migrating legacy `connect` config from config.json to a \
4142 standalone connect.json"
4143 );
4144 strip_legacy_connect_key_from_config_json(data_dir);
4148 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4149 tracing::error!("Failed to write connect.json during migration: {}", e);
4150 }
4151 }
4152 }
4153 Err(e) => {
4154 tracing::error!(
4155 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4156 connect_path,
4157 e
4158 );
4159 self.connect = ConnectConfig::default();
4160 }
4161 }
4162 }
4163
4164 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4167 serde_json::from_str::<Config>(content).map(|mut config| {
4168 config.hydrate_proxy_auth_from_encrypted();
4169 config.hydrate_provider_api_keys_from_encrypted();
4170 config.hydrate_provider_instance_api_keys_from_encrypted();
4171 config.hydrate_mcp_secrets_from_encrypted();
4172 config.hydrate_env_vars_from_encrypted();
4173 config.hydrate_cluster_fabric_from_encrypted();
4174 config.hydrate_broker_token_from_encrypted();
4175 config.hydrate_notifications_from_encrypted();
4176 config.hydrate_connect_platform_tokens_from_encrypted();
4177 config.normalize_tool_settings();
4178 config.normalize_skill_settings();
4179 config
4180 })
4181 }
4182
4183 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4189 let config_path = data_dir.join("config.json");
4190 for gen in 0..BAK_GENERATIONS {
4191 let backup = backup_path_for(&config_path, gen);
4192 let Ok(content) = std::fs::read_to_string(&backup) else {
4193 continue;
4194 };
4195 match Self::parse_and_hydrate(&content) {
4196 Ok(config) => {
4197 tracing::info!("Recovered configuration from {:?}", backup);
4198 return Some((config, gen));
4199 }
4200 Err(e) => {
4201 tracing::warn!(
4202 "Backup {:?} is unparseable ({}); trying an older generation",
4203 backup,
4204 e
4205 );
4206 }
4207 }
4208 }
4209 None
4210 }
4211
4212 const SALVAGE_MAX_KEYS: usize = 512;
4218
4219 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4246 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4249 let corrupt_obj = corrupt.as_object()?;
4250 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4251 tracing::warn!(
4252 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4253 corrupt_obj.len(),
4254 Self::SALVAGE_MAX_KEYS
4255 );
4256 return None;
4257 }
4258
4259 let mut base = Self::load_backup(data_dir)
4263 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4264 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4265 let base_obj = base.as_object_mut()?;
4266
4267 let mut salvaged: Vec<String> = Vec::new();
4268 for (key, value) in corrupt_obj {
4269 let previous = base_obj.insert(key.clone(), value.clone());
4270 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4274 salvaged.push(key.clone());
4275 } else {
4276 match previous {
4277 Some(prev) => {
4278 base_obj.insert(key.clone(), prev);
4279 }
4280 None => {
4281 base_obj.remove(key);
4282 }
4283 }
4284 }
4285 }
4286
4287 tracing::warn!(
4288 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4289 last-known-good/default value",
4290 salvaged.len(),
4291 salvaged.join(", ")
4292 );
4293
4294 let rebuilt = serde_json::to_string(&base).ok()?;
4298 Self::parse_and_hydrate(&rebuilt)
4299 .ok()
4300 .map(|config| (config, salvaged))
4301 }
4302
4303 pub fn get_model(&self) -> Option<String> {
4311 if self.features.provider_model_ref {
4312 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4313 return Some(model_ref.model.clone());
4314 }
4315 }
4316 let provider = self.effective_default_provider();
4317 if let Some(instance) = self.provider_instances.get(provider) {
4318 return instance
4319 .model
4320 .clone()
4321 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4322 }
4323 match provider {
4324 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4325 "anthropic" => self
4326 .providers
4327 .anthropic
4328 .as_ref()
4329 .and_then(|c| c.model.clone()),
4330 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4331 "copilot" => Some(
4332 self.providers
4333 .copilot
4334 .as_ref()
4335 .and_then(|c| c.model.clone())
4336 .unwrap_or_else(|| "gpt-4o".to_string()),
4337 ),
4338 _ => None,
4339 }
4340 }
4341
4342 pub fn get_fast_model(&self) -> Option<String> {
4350 if self.features.provider_model_ref {
4351 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4352 return Some(model_ref.model.clone());
4353 }
4354 }
4355 let provider = self.effective_default_provider();
4356 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4357 instance.fast_model.clone()
4358 } else {
4359 match provider {
4360 "openai" => self
4361 .providers
4362 .openai
4363 .as_ref()
4364 .and_then(|c| c.fast_model.clone()),
4365 "anthropic" => self
4366 .providers
4367 .anthropic
4368 .as_ref()
4369 .and_then(|c| c.fast_model.clone()),
4370 "gemini" => self
4371 .providers
4372 .gemini
4373 .as_ref()
4374 .and_then(|c| c.fast_model.clone()),
4375 "copilot" => self
4376 .providers
4377 .copilot
4378 .as_ref()
4379 .and_then(|c| c.fast_model.clone()),
4380 _ => None,
4381 }
4382 };
4383 fast.or_else(|| self.get_model())
4384 }
4385
4386 pub fn get_task_summary_model(&self) -> Option<String> {
4394 if self.features.provider_model_ref {
4395 if let Some(model_ref) = self
4396 .defaults
4397 .as_ref()
4398 .and_then(|d| d.task_summary.as_ref())
4399 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4400 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4401 {
4402 return Some(model_ref.model.clone());
4403 }
4404 }
4405
4406 self.get_fast_model()
4407 }
4408
4409 pub fn get_memory_background_model(&self) -> Option<String> {
4417 if self.features.provider_model_ref {
4418 if let Some(model_ref) = self
4419 .defaults
4420 .as_ref()
4421 .and_then(|d| d.memory_background.as_ref())
4422 {
4423 return Some(model_ref.model.clone());
4424 }
4425 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4426 return Some(model_ref.model.clone());
4427 }
4428 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4429 return Some(model_ref.model.clone());
4430 }
4431 }
4432 let configured = self
4433 .memory
4434 .as_ref()
4435 .and_then(|memory| memory.background_model.as_ref())
4436 .map(|value| value.trim())
4437 .filter(|value| !value.is_empty())
4438 .map(ToString::to_string);
4439 configured.or_else(|| self.get_fast_model())
4440 }
4441
4442 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4450 let raw = self
4451 .default_work_area
4452 .as_ref()
4453 .and_then(|config| config.path.as_ref())
4454 .map(|value| value.trim())
4455 .filter(|value| !value.is_empty())?;
4456
4457 let candidate = expand_user_path(raw);
4458 if candidate.is_absolute() {
4459 let canonical = std::fs::canonicalize(&candidate).ok();
4460 return canonical
4461 .as_ref()
4462 .filter(|path| path.is_dir())
4463 .map(|_| candidate.clone())
4464 .or_else(|| candidate.is_dir().then_some(candidate));
4465 }
4466
4467 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4468 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4469 canonical
4470 .as_ref()
4471 .filter(|path| path.is_dir())
4472 .map(|_| from_bamboo_dir.clone())
4473 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4474 .or_else(|| candidate.is_dir().then_some(candidate))
4475 }
4476
4477 pub fn get_vision_model(&self) -> Option<String> {
4483 if self.features.provider_model_ref {
4484 if let Some(model_ref) = self
4485 .defaults
4486 .as_ref()
4487 .and_then(|defaults| defaults.vision.as_ref())
4488 .or_else(|| {
4489 self.defaults
4490 .as_ref()
4491 .and_then(|defaults| defaults.fast.as_ref())
4492 })
4493 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4494 {
4495 return Some(model_ref.model.clone());
4496 }
4497 }
4498 let provider = self.effective_default_provider();
4499 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4500 instance.vision_model.clone()
4501 } else {
4502 match provider {
4503 "openai" => self
4504 .providers
4505 .openai
4506 .as_ref()
4507 .and_then(|c| c.vision_model.clone()),
4508 "anthropic" => self
4509 .providers
4510 .anthropic
4511 .as_ref()
4512 .and_then(|c| c.vision_model.clone()),
4513 "gemini" => self
4514 .providers
4515 .gemini
4516 .as_ref()
4517 .and_then(|c| c.vision_model.clone()),
4518 "copilot" => self
4519 .providers
4520 .copilot
4521 .as_ref()
4522 .and_then(|c| c.vision_model.clone()),
4523 _ => None,
4524 }
4525 };
4526 vision.or_else(|| self.get_fast_model())
4527 }
4528
4529 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4533 if self.features.provider_model_ref {
4534 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4535 return chat
4536 .reasoning_effort
4537 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4538 }
4539 }
4540 self.reasoning_effort_for_key(self.effective_default_provider())
4541 }
4542
4543 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4553 let trimmed = key.trim();
4554 if trimmed.is_empty() {
4555 return None;
4556 }
4557
4558 if let Some(instance) = self.provider_instances.get(trimmed) {
4560 return instance.reasoning_effort;
4561 }
4562
4563 match trimmed {
4565 "openai" => self
4566 .providers
4567 .openai
4568 .as_ref()
4569 .and_then(|c| c.reasoning_effort),
4570 "anthropic" => self
4571 .providers
4572 .anthropic
4573 .as_ref()
4574 .and_then(|c| c.reasoning_effort),
4575 "gemini" => self
4576 .providers
4577 .gemini
4578 .as_ref()
4579 .and_then(|c| c.reasoning_effort),
4580 "copilot" => self
4581 .providers
4582 .copilot
4583 .as_ref()
4584 .and_then(|c| c.reasoning_effort),
4585 "bodhi" => self
4586 .providers
4587 .bodhi
4588 .as_ref()
4589 .and_then(|c| c.reasoning_effort),
4590 _ => None,
4591 }
4592 }
4593
4594 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4601 self.tools
4602 .disabled
4603 .iter()
4604 .map(|name| name.trim())
4605 .filter(|name| !name.is_empty())
4606 .map(str::to_string)
4607 .collect()
4608 }
4609
4610 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4615 self.disabled_tool_references()
4616 .into_iter()
4617 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4618 .collect()
4619 }
4620
4621 pub fn normalize_tool_settings(&mut self) {
4623 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4624 }
4625
4626 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4628 self.skills
4629 .disabled
4630 .iter()
4631 .map(|id| id.trim())
4632 .filter(|id| !id.is_empty())
4633 .map(|id| id.to_string())
4634 .collect()
4635 }
4636
4637 pub fn normalize_skill_settings(&mut self) {
4639 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4640 }
4641
4642 pub fn normalize_plugin_trust_settings(&mut self) {
4650 self.plugin_trust.trusted_hosts = self
4651 .plugin_trust
4652 .trusted_hosts
4653 .iter()
4654 .map(|entry| entry.trim().to_ascii_lowercase())
4655 .filter(|entry| !entry.is_empty())
4656 .collect();
4657 }
4658
4659 pub fn effective_default_provider(&self) -> &str {
4664 self.default_provider_instance
4665 .as_deref()
4666 .unwrap_or(&self.provider)
4667 }
4668
4669 pub fn has_provider_instances(&self) -> bool {
4671 !self.provider_instances.is_empty()
4672 }
4673
4674 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4676 self.env_vars
4677 .iter()
4678 .filter(|e| !e.value.trim().is_empty())
4679 .map(|e| (e.name.clone(), e.value.clone()))
4680 .collect()
4681 }
4682
4683 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4684 self.env_vars
4685 .iter()
4686 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4687 .map(|entry| PromptSafeEnvVarEntry {
4688 name: entry.name.clone(),
4689 secret: entry.secret,
4690 description: entry
4691 .description
4692 .as_ref()
4693 .map(|value| value.trim().to_string())
4694 .filter(|value| !value.is_empty()),
4695 })
4696 .collect()
4697 }
4698
4699 pub fn publish_env_vars(&self) {
4701 let map = self.env_vars_as_map();
4702
4703 #[cfg(any(test, feature = "test-utils"))]
4704 if crate::test_support::env_vars_cache_override_is_active() {
4705 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4706 return;
4707 }
4708
4709 let mut env_guard = env_vars_cache().write().recover_poison();
4710 *env_guard = map;
4711
4712 let prompt_safe = self.prompt_safe_env_vars();
4713 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4714 *prompt_guard = prompt_safe;
4715 }
4716
4717 pub fn current_env_vars() -> HashMap<String, String> {
4719 #[cfg(any(test, feature = "test-utils"))]
4720 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4721 return env_vars;
4722 }
4723
4724 env_vars_cache().read().recover_poison().clone()
4725 }
4726
4727 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4729 #[cfg(any(test, feature = "test-utils"))]
4730 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4731 return env_vars;
4732 }
4733
4734 prompt_safe_env_vars_cache().read().recover_poison().clone()
4735 }
4736
4737 fn create_default() -> Self {
4739 Self::from_parts(
4740 ConfigValues {
4741 http_proxy: String::new(),
4742 https_proxy: String::new(),
4743 proxy_auth: None,
4744 proxy_auth_encrypted: None,
4745 proxy_auth_credential_ref: None,
4746 headless_auth: false,
4747 run_budget: RunBudgetConfig::default(),
4748 stream_timeout: StreamTimeoutConfig::default(),
4749 context_management: ContextManagementConfig::default(),
4750 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4751 provider: default_provider(),
4752 provider_instances: HashMap::new(),
4753 default_provider_instance: None,
4754 server: ServerConfig::default(),
4755 keyword_masking: KeywordMaskingConfig::default(),
4756 anthropic_model_mapping: AnthropicModelMapping::default(),
4757 gemini_model_mapping: GeminiModelMapping::default(),
4758 hooks: HooksConfig::default(),
4759 lifecycle_hooks: LifecycleHooksConfig::default(),
4760 tools: ToolsConfig::default(),
4761 skills: SkillsConfig::default(),
4762 env_vars: Vec::new(),
4763 default_work_area: None,
4764 access_control: None,
4765 features: FeatureFlags::default(),
4766 defaults: None,
4767 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4768 notifications: NotificationsConfig::default(),
4769 connect: ConnectConfig::default(),
4770 plugin_trust: PluginTrustConfig::default(),
4771 extra: BTreeMap::new(),
4772 },
4773 None,
4774 SubagentsConfig::default(),
4775 ProviderConfigs::default(),
4776 )
4777 }
4778
4779 pub fn server_addr(&self) -> String {
4781 format!("{}:{}", self.server.bind, self.server.port)
4782 }
4783
4784 pub fn save(&self) -> Result<()> {
4786 self.save_to_dir(default_data_dir())
4787 }
4788
4789 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4791 self.memory.save_sync(data_dir)
4792 }
4793
4794 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4796 self.subagents.save_sync(data_dir)
4797 }
4798
4799 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4802 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4803 let mut config = self.clone();
4804 config.clear_legacy_provider_aliases_for_instance_mode();
4805 config.refresh_provider_api_keys_encrypted()?;
4806 config.providers.save_sync(data_dir)
4807 }
4808
4809 pub(crate) fn prepare_provider_transaction_documents(
4813 &self,
4814 provider_document: &[u8],
4815 ) -> Result<(Vec<u8>, Vec<u8>)> {
4816 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4817 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4818 anyhow::bail!(
4819 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4820 status.source
4821 );
4822 }
4823
4824 let mut to_save = self.clone();
4825 to_save.clear_legacy_provider_aliases_for_instance_mode();
4826 to_save.extra.remove("data_dir");
4827 to_save.extra.remove("model");
4828 to_save.refresh_encrypted_secrets()?;
4829 to_save.ensure_provider_instance_credentials_isolated()?;
4830 to_save.sanitize_mcp_credential_refs_for_disk();
4831 to_save.sanitize_env_vars_for_disk();
4832 to_save.sanitize_notifications_for_disk();
4833 to_save.sanitize_cluster_fabric_for_disk();
4834 to_save.assign_connect_platform_ids();
4835 to_save.normalize_tool_settings();
4836 to_save.normalize_skill_settings();
4837
4838 let mut root = durable_root_value(to_save.values.clone())
4839 .context("Failed to serialize root config DTO to JSON")?;
4840 if let Some(object) = root.as_object_mut() {
4841 object.remove("connect");
4842 }
4843 let root = serde_json::to_vec_pretty(&root)?;
4844
4845 let mut providers = to_save.providers.0.clone();
4846 macro_rules! sanitize_provider {
4847 ($field:ident) => {
4848 if let Some(provider) = providers.$field.as_mut() {
4849 if !provider.api_key.trim().is_empty()
4850 && !provider.api_key_from_env
4851 && provider.credential_ref.is_none()
4852 {
4853 anyhow::bail!(
4854 "provider secret requires credential transaction before persistence"
4855 );
4856 }
4857 provider.api_key_encrypted = None;
4858 }
4859 };
4860 }
4861 sanitize_provider!(openai);
4862 sanitize_provider!(anthropic);
4863 sanitize_provider!(gemini);
4864 if let Some(provider) = providers.bodhi.as_mut() {
4865 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4866 anyhow::bail!("provider secret requires credential transaction before persistence");
4867 }
4868 provider.api_key_encrypted = None;
4869 }
4870
4871 let existing_provider_value = if provider_document.is_empty() {
4872 None
4873 } else {
4874 Some(
4875 serde_json::from_slice::<Value>(provider_document)
4876 .context("provider metadata document is invalid")?,
4877 )
4878 };
4879 let provider_value = match existing_provider_value {
4880 Some(Value::Object(mut envelope))
4881 if envelope.contains_key("schema_version")
4882 || envelope.contains_key("revision")
4883 || envelope.contains_key("data") =>
4884 {
4885 let revision = envelope
4886 .get("revision")
4887 .and_then(Value::as_u64)
4888 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4889 let schema_version = envelope
4890 .get("schema_version")
4891 .and_then(Value::as_u64)
4892 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4893 if schema_version != 1 || !envelope.contains_key("data") {
4894 anyhow::bail!("provider revision envelope is unsupported");
4895 }
4896 let revision = revision
4897 .checked_add(1)
4898 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4899 envelope.insert("revision".into(), Value::from(revision));
4900 envelope.insert("data".into(), serde_json::to_value(providers)?);
4901 Value::Object(envelope)
4902 }
4903 Some(_) | None => serde_json::to_value(providers)?,
4904 };
4905 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4906 }
4907
4908 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4912 self.recovery_status.as_ref()
4913 }
4914
4915 pub fn confirm_recovery(&mut self) {
4921 if let Some(status) = self.recovery_status.as_mut() {
4922 status.confirmed = true;
4923 }
4924 }
4925
4926 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4934 if self.recovery_status.is_none() {
4935 anyhow::bail!("No pending config-corruption recovery to confirm");
4936 }
4937 self.confirm_recovery();
4938 self.save_to_dir(data_dir)?;
4939 self.recovery_status = None;
4940 Ok(())
4941 }
4942
4943 pub fn assign_connect_platform_ids(&mut self) {
4959 for platform in &mut self.connect.platforms {
4960 if platform.id.is_none() {
4961 platform.id = Some(uuid::Uuid::new_v4().to_string());
4962 }
4963 }
4964 }
4965
4966 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
4978 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4979 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4980 anyhow::bail!(
4981 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
4982 has not been confirmed; the corrupt original is preserved at {:?}. Call \
4983 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
4984 status.source,
4985 status.quarantine_path,
4986 );
4987 }
4988 if self.proxy_auth_credential_ref.is_none()
4989 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
4990 {
4991 anyhow::bail!(
4992 "proxy auth requires the isolated credential transaction before persistence"
4993 );
4994 }
4995 if self.env_vars.iter().any(|entry| {
4996 entry.secret
4997 && entry.credential_ref.is_none()
4998 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
4999 }) {
5000 anyhow::bail!(
5001 "secret env vars require the isolated credential transaction before persistence"
5002 );
5003 }
5004 if [
5005 (
5006 &self.notifications.ntfy.token,
5007 &self.notifications.ntfy.token_encrypted,
5008 &self.notifications.ntfy.credential_ref,
5009 ),
5010 (
5011 &self.notifications.bark.device_key,
5012 &self.notifications.bark.device_key_encrypted,
5013 &self.notifications.bark.credential_ref,
5014 ),
5015 ]
5016 .iter()
5017 .any(|(plaintext, ciphertext, reference)| {
5018 reference.is_none()
5019 && (plaintext
5020 .as_deref()
5021 .is_some_and(|value| !value.trim().is_empty())
5022 || ciphertext.is_some())
5023 }) {
5024 anyhow::bail!(
5025 "notification secrets require the isolated credential transaction before persistence"
5026 );
5027 }
5028
5029 if crate::modular_authority_boundary_present(&data_dir)
5030 .context("Failed to inspect modular configuration authority boundary")?
5031 {
5032 crate::ConfigFacade::open_or_migrate(&data_dir)
5033 .context("Failed to recover modular configuration before persistence")?;
5034 crate::persist_facade_effective_config(&data_dir, self)
5035 .context("Failed to persist modular configuration sections")?;
5036 return Ok(());
5037 }
5038
5039 if crate::section_layout_is_active(&data_dir)
5040 .context("Failed to inspect modular configuration layout")?
5041 {
5042 crate::persist_facade_effective_config(&data_dir, self)
5043 .context("Failed to persist modular configuration sections")?;
5044 return Ok(());
5045 }
5046
5047 let path = data_dir.join("config.json");
5048
5049 if let Some(parent) = path.parent() {
5050 std::fs::create_dir_all(parent)
5051 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5052 }
5053
5054 let mut to_save = self.clone();
5055 to_save.clear_legacy_provider_aliases_for_instance_mode();
5056 to_save.extra.remove("data_dir");
5058 to_save.extra.remove("model");
5060 to_save.refresh_encrypted_secrets()?;
5063 to_save.ensure_provider_instance_credentials_isolated()?;
5064 to_save.sanitize_mcp_credential_refs_for_disk();
5065 to_save.sanitize_env_vars_for_disk();
5066 to_save.sanitize_notifications_for_disk();
5067 to_save.sanitize_cluster_fabric_for_disk();
5068 to_save.assign_connect_platform_ids();
5069 to_save.normalize_tool_settings();
5070 to_save.normalize_skill_settings();
5071
5072 let mut config_value = durable_root_value(to_save.values.clone())
5082 .context("Failed to serialize root config DTO to JSON")?;
5083 if let Some(obj) = config_value.as_object_mut() {
5084 obj.remove("connect");
5085 }
5086 let content = serde_json::to_string_pretty(&config_value)
5087 .context("Failed to serialize config to JSON")?;
5088
5089 to_save.memory.save_sync(&data_dir)?;
5096 to_save.subagents.save_sync(&data_dir)?;
5097 to_save.providers.save_sync(&data_dir)?;
5098
5099 if path.exists()
5106 && std::fs::read_to_string(&path)
5107 .ok()
5108 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5109 {
5110 rotate_backups(&path, BAK_GENERATIONS);
5114 let backup = backup_path_for(&path, 0);
5115 let backup_result = std::fs::read(&path).and_then(|bytes| {
5116 let mut value: Value = serde_json::from_slice(&bytes)
5117 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5118 if sanitize_ref_backed_mcp_json(&mut value) {
5119 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5120 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5121 })?;
5122 write_atomic(&backup, &sanitized)
5123 } else {
5124 std::fs::copy(&path, &backup).map(|_| ())
5125 }
5126 });
5127 if let Err(e) = backup_result {
5128 tracing::warn!("Failed to back up config.json before save: {}", e);
5129 }
5130 scrub_ref_backed_mcp_from_config_backups(&path);
5131 }
5132
5133 write_atomic(&path, content.as_bytes())
5134 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5135
5136 save_connect_config(&to_save.connect, &data_dir)?;
5137
5138 Ok(())
5139 }
5140}
5141
5142fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5147 let Some(object) = root.as_object_mut() else {
5148 return false;
5149 };
5150 let Some(mcp) = (if object.contains_key("mcpServers") {
5151 object.get_mut("mcpServers")
5152 } else {
5153 object.get_mut("mcp")
5154 }) else {
5155 return false;
5156 };
5157 let mut changed = false;
5158 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5159 for server in servers {
5160 if let Some(object) = server.as_object_mut() {
5161 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5162 {
5163 changed |= sanitize_ref_backed_mcp_transport(transport);
5164 }
5165 }
5166 }
5167 } else if let Some(servers) = mcp.as_object_mut() {
5168 for server in servers.values_mut() {
5169 if let Some(object) = server.as_object_mut() {
5170 changed |= sanitize_ref_backed_mcp_transport(object);
5171 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5172 {
5173 changed |= sanitize_ref_backed_mcp_transport(transport);
5174 }
5175 }
5176 }
5177 }
5178 changed
5179}
5180
5181fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5182 let mut changed = false;
5183 let env_names = object
5184 .get("env_credential_refs")
5185 .and_then(Value::as_object)
5186 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5187 .unwrap_or_default();
5188 for name in env_names {
5189 for field in ["env", "env_encrypted"] {
5190 if object
5191 .get_mut(field)
5192 .and_then(Value::as_object_mut)
5193 .and_then(|values| values.remove(&name))
5194 .is_some()
5195 {
5196 changed = true;
5197 }
5198 }
5199 }
5200 let header_names = object
5201 .get("header_credential_refs")
5202 .and_then(Value::as_object)
5203 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5204 .unwrap_or_default();
5205 for name in header_names {
5206 for field in ["headers", "headers_encrypted"] {
5207 if object
5208 .get_mut(field)
5209 .and_then(Value::as_object_mut)
5210 .and_then(|values| values.remove(&name))
5211 .is_some()
5212 {
5213 changed = true;
5214 }
5215 }
5216 }
5217 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5218 for header in headers {
5219 let Some(header) = header.as_object_mut() else {
5220 continue;
5221 };
5222 if header.get("credential_ref").is_some_and(Value::is_string) {
5223 changed |= header.remove("value").is_some();
5224 changed |= header.remove("value_encrypted").is_some();
5225 }
5226 }
5227 }
5228 changed
5229}
5230
5231fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5232 for generation in 0..BAK_GENERATIONS {
5233 let backup = backup_path_for(path, generation);
5234 let Ok(bytes) = std::fs::read(&backup) else {
5235 continue;
5236 };
5237 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5238 continue;
5239 };
5240 if sanitize_ref_backed_mcp_json(&mut value) {
5241 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5242 if let Err(error) = write_atomic(&backup, &sanitized) {
5243 tracing::warn!(
5244 "Failed to scrub MCP credentials from {:?}: {}",
5245 backup,
5246 error
5247 );
5248 }
5249 }
5250 }
5251 }
5252}
5253
5254fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5264 let path = data_dir.join("connect.json");
5265 if connect_config_is_empty(connect) && !path.exists() {
5266 return Ok(());
5267 }
5268
5269 if path.exists() {
5270 let backup = path.with_extension("json.bak");
5271 if let Err(e) = std::fs::copy(&path, &backup) {
5272 tracing::warn!("Failed to back up connect.json before save: {}", e);
5273 }
5274 }
5275
5276 let content = serde_json::to_string_pretty(connect)
5277 .context("Failed to serialize connect config to JSON")?;
5278 write_atomic(&path, content.as_bytes())
5279 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5280 Ok(())
5281}
5282
5283fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5301 let config_path = data_dir.join("config.json");
5302 let content = match std::fs::read_to_string(&config_path) {
5303 Ok(content) => content,
5304 Err(e) => {
5305 tracing::error!(
5306 "Failed to read config.json to strip legacy `connect` key: {}",
5307 e
5308 );
5309 return;
5310 }
5311 };
5312 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5313 Ok(value) => value,
5314 Err(e) => {
5315 tracing::error!(
5316 "Failed to parse config.json to strip legacy `connect` key: {}",
5317 e
5318 );
5319 return;
5320 }
5321 };
5322 let Some(obj) = value.as_object_mut() else {
5323 return;
5324 };
5325 if obj.remove("connect").is_none() {
5326 return;
5329 }
5330 let rewritten = match serde_json::to_string_pretty(&value) {
5331 Ok(rewritten) => rewritten,
5332 Err(e) => {
5333 tracing::error!(
5334 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5335 e
5336 );
5337 return;
5338 }
5339 };
5340 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5341 tracing::error!(
5342 "Failed to write config.json after stripping legacy `connect` key: {}",
5343 e
5344 );
5345 }
5346}
5347
5348fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5385 let config_path = data_dir.join("config.json");
5386 for gen in 0..BAK_GENERATIONS {
5387 let backup = backup_path_for(&config_path, gen);
5388 let content = match std::fs::read_to_string(&backup) {
5389 Ok(content) => content,
5390 Err(e) => {
5391 if e.kind() != std::io::ErrorKind::NotFound {
5392 tracing::warn!(
5393 "Failed to read {:?} while scanning for legacy connect data ({}); \
5394 leaving it untouched",
5395 backup,
5396 e
5397 );
5398 }
5399 continue;
5400 }
5401 };
5402 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5403 Ok(value) => value,
5404 Err(e) => {
5405 tracing::warn!(
5406 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5407 ({}); left untouched (never deleted)",
5408 backup,
5409 e
5410 );
5411 continue;
5412 }
5413 };
5414 let Some(obj) = value.as_object_mut() else {
5415 continue;
5418 };
5419 if obj.remove("connect").is_none() {
5420 continue;
5423 }
5424 let rewritten = match serde_json::to_string_pretty(&value) {
5425 Ok(rewritten) => rewritten,
5426 Err(e) => {
5427 tracing::error!(
5428 "Failed to serialize {:?} after stripping legacy connect data: {}",
5429 backup,
5430 e
5431 );
5432 continue;
5433 }
5434 };
5435 match write_atomic(&backup, rewritten.as_bytes()) {
5436 Ok(()) => tracing::info!(
5437 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5438 backup
5439 ),
5440 Err(e) => tracing::error!(
5441 "Failed to write {:?} after stripping legacy connect data: {}",
5442 backup,
5443 e
5444 ),
5445 }
5446 }
5447}
5448
5449fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5464 let backup = connect_path.with_extension("json.bak");
5465 match std::fs::rename(connect_path, &backup) {
5466 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5467 Err(e) => {
5468 tracing::warn!(
5469 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5470 backup,
5471 e
5472 );
5473 if let Err(e) = std::fs::copy(connect_path, &backup) {
5474 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5475 return;
5476 }
5477 if let Err(e) = std::fs::remove_file(connect_path) {
5478 tracing::error!(
5479 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5480 original {:?}: {}",
5481 backup,
5482 connect_path,
5483 e
5484 );
5485 }
5486 }
5487 }
5488}
5489
5490const QUARANTINE_KEEP: usize = 5;
5493
5494fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5503 let nanos = std::time::SystemTime::now()
5504 .duration_since(std::time::UNIX_EPOCH)
5505 .map(|d| d.as_nanos())
5506 .unwrap_or(0);
5507 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5511 let mut dedup = 1u32;
5512 while quarantine.exists() {
5513 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5514 dedup += 1;
5515 }
5516 let result = match std::fs::copy(config_path, &quarantine) {
5517 Ok(_) => {
5518 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5519 Some(quarantine)
5520 }
5521 Err(e) => {
5522 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5523 None
5524 }
5525 };
5526 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5527 result
5528}
5529
5530fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5533 let Some(dir) = config_path.parent() else {
5534 return;
5535 };
5536 let prefix = "config.json.corrupted.";
5537 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5538 Ok(entries) => entries
5539 .filter_map(|e| e.ok())
5540 .map(|e| e.path())
5541 .filter(|p| {
5542 p.file_name()
5543 .and_then(|n| n.to_str())
5544 .is_some_and(|n| n.starts_with(prefix))
5545 })
5546 .collect(),
5547 Err(_) => return,
5548 };
5549 if quarantines.len() <= keep {
5550 return;
5551 }
5552 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5554 let remove = quarantines.len() - keep;
5555 for stale in quarantines.into_iter().take(remove) {
5556 if let Err(e) = std::fs::remove_file(&stale) {
5557 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5558 }
5559 }
5560}
5561
5562const BAK_GENERATIONS: usize = 3;
5565
5566fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5568 if gen == 0 {
5569 config_path.with_extension("json.bak")
5570 } else {
5571 config_path.with_extension(format!("json.bak.{gen}"))
5572 }
5573}
5574
5575fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5581 for gen in (1..generations).rev() {
5582 let from = backup_path_for(config_path, gen - 1);
5583 let to = backup_path_for(config_path, gen);
5584 if from.exists() {
5585 if let Err(e) = std::fs::rename(&from, &to) {
5586 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5587 }
5588 }
5589 }
5590}
5591
5592pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5593 crate::config_store::AtomicFileStore::new(path)
5594 .write_bytes_without_backup(content)
5595 .map_err(|error| match error {
5596 crate::config_store::ConfigStoreError::Io(error) => error,
5597 other => std::io::Error::other(other),
5598 })
5599}
5600
5601#[cfg(test)]
5602mod tests {
5603 use super::*;
5604 use std::ffi::OsString;
5605 use std::path::PathBuf;
5606 use std::sync::Mutex;
5607 use std::time::{SystemTime, UNIX_EPOCH};
5608
5609 #[test]
5610 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5611 let input = serde_json::json!({
5612 "disabled": ["bash"],
5613 "plugin_runtime": {
5614 "timeout_ms": 5_000,
5615 "sandbox": true
5616 },
5617 "future_flag": "enabled"
5618 });
5619
5620 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5621 assert_eq!(config.disabled, vec!["bash"]);
5622 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5623 assert_eq!(config.extra["future_flag"], "enabled");
5624 assert_eq!(serde_json::to_value(config).unwrap(), input);
5625 }
5626
5627 #[test]
5628 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5629 let input = serde_json::json!({
5630 "tool_extension": {
5631 "mode": "strict",
5632 "options": ["one", "two"]
5633 }
5634 });
5635 let config: Config = serde_json::from_value(serde_json::json!({
5636 "tools": input.clone()
5637 }))
5638 .unwrap();
5639
5640 assert!(config.tools.disabled.is_empty());
5641 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5642 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5643
5644 let temp_home = TempHome::new();
5645 config.save_to_dir(temp_home.path.clone()).unwrap();
5646 let persisted: Value =
5647 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5648 .unwrap();
5649 assert_eq!(persisted["tools"], input);
5650 }
5651
5652 #[test]
5653 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5654 let input = serde_json::json!({
5655 "external_catalog": {
5656 "path": "/opt/bamboo/skills",
5657 "refresh": false
5658 },
5659 "schema_version": 2
5660 });
5661
5662 let config: Config = serde_json::from_value(serde_json::json!({
5663 "skills": input.clone()
5664 }))
5665 .unwrap();
5666 assert!(config.skills.disabled.is_empty());
5667 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5668 assert_eq!(config.skills.extra["schema_version"], 2);
5669 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5670
5671 let temp_home = TempHome::new();
5672 config.save_to_dir(temp_home.path.clone()).unwrap();
5673 let persisted: Value =
5674 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5675 .unwrap();
5676 assert_eq!(persisted["skills"], input);
5677 }
5678
5679 #[test]
5680 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5681 let config: Config = serde_json::from_value(serde_json::json!({
5682 "hooks": {
5683 "image_fallback": {"enabled": true, "mode": "placeholder"}
5684 },
5685 "lifecycle_hooks": {
5686 "enabled": true,
5687 "PreToolUse": [{
5688 "matcher": "bash|write_file",
5689 "hooks": [{"type": "command", "command": "guard.sh"}]
5690 }],
5691 "SessionStart": [{
5692 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5693 }]
5694 }
5695 }))
5696 .expect("lifecycle hook config should deserialize");
5697
5698 assert!(config.lifecycle_hooks.enabled);
5699 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5700 assert!(
5701 config.lifecycle_hooks.pre_tool_use[0].enabled,
5702 "legacy groups without an enabled flag remain active"
5703 );
5704 assert_eq!(
5705 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5706 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5707 );
5708 assert_eq!(
5709 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5710 25
5711 );
5712
5713 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5714 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5715 assert_eq!(
5716 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5717 "bash|write_file"
5718 );
5719 assert_eq!(
5720 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5721 "command"
5722 );
5723 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5724 .get("enabled")
5725 .is_none());
5726 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5727 .get("timeout_ms")
5728 .is_none());
5729 assert!(json.get("hooks").is_some());
5730 }
5731
5732 #[test]
5733 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5734 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5735 "type": "script",
5736 "path": ".bamboo/hooks/check.js"
5737 }))
5738 .expect("script lifecycle hook should deserialize");
5739
5740 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5741 assert!(matches!(
5742 handler,
5743 LifecycleHookHandler::Script {
5744 runner: LifecycleScriptRunner::Auto,
5745 ..
5746 }
5747 ));
5748 let json = serde_json::to_value(handler).expect("script hook should serialize");
5749 assert_eq!(json["type"], "script");
5750 assert!(json.get("timeout_ms").is_none());
5751 assert!(json.get("runner").is_none());
5752 }
5753
5754 #[test]
5755 fn script_runner_support_is_extension_aware() {
5756 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5757 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5758 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5759 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5760 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5761 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5762 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5763 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5764 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5765 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5766 }
5767
5768 #[test]
5769 fn script_runner_names_round_trip_through_config_json() {
5770 for (runner, name) in [
5771 (LifecycleScriptRunner::Auto, "auto"),
5772 (LifecycleScriptRunner::Node, "node"),
5773 (LifecycleScriptRunner::Bun, "bun"),
5774 (LifecycleScriptRunner::Python, "python"),
5775 (LifecycleScriptRunner::Bash, "bash"),
5776 (LifecycleScriptRunner::PowerShell, "powershell"),
5777 (LifecycleScriptRunner::Cmd, "cmd"),
5778 ] {
5779 let json = serde_json::to_value(runner).unwrap();
5780 assert_eq!(json, name);
5781 assert_eq!(
5782 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5783 runner
5784 );
5785 }
5786 }
5787
5788 #[test]
5789 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5790 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5791 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5792 let json = serde_json::to_value(&config).unwrap();
5793 assert!(json.get("lifecycle_hooks").is_none());
5794 }
5795
5796 #[test]
5797 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5798 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5799 .expect("legacy config without stream_timeout should load");
5800 let values = ConfigValues::from(root);
5801
5802 assert_eq!(
5803 values.stream_timeout,
5804 StreamTimeoutConfig {
5805 transport_idle_timeout_secs: 120,
5806 first_semantic_timeout_secs: 600,
5807 semantic_idle_timeout_secs: 600,
5808 }
5809 );
5810 values
5811 .stream_timeout
5812 .validate()
5813 .expect("defaults are valid");
5814 }
5815
5816 #[test]
5817 fn stream_timeout_round_trips_through_persistence_dto() {
5818 let values = ConfigValues {
5819 stream_timeout: StreamTimeoutConfig {
5820 transport_idle_timeout_secs: 45,
5821 first_semantic_timeout_secs: 900,
5822 semantic_idle_timeout_secs: 300,
5823 },
5824 ..ConfigValues::default()
5825 };
5826
5827 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5828 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5829 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5830 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5831
5832 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5833 assert_eq!(
5834 ConfigValues::from(root).stream_timeout,
5835 StreamTimeoutConfig {
5836 transport_idle_timeout_secs: 45,
5837 first_semantic_timeout_secs: 900,
5838 semantic_idle_timeout_secs: 300,
5839 }
5840 );
5841 }
5842
5843 #[test]
5844 fn context_management_defaults_to_summary_and_is_omitted() {
5845 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5846 assert_eq!(
5847 config.context_management.strategy,
5848 ContextManagementStrategy::Summary
5849 );
5850 assert_eq!(
5851 config
5852 .context_management
5853 .retrieval_window
5854 .min_recent_user_turns,
5855 3
5856 );
5857 assert_eq!(
5858 config.context_management.retrieval_target_usage_percent(),
5859 60
5860 );
5861 let json = serde_json::to_value(&config).unwrap();
5862 assert!(json.get("context_management").is_none());
5863 }
5864
5865 #[test]
5866 fn retrieval_window_context_management_round_trips() {
5867 let json = serde_json::json!({
5868 "context_management": {
5869 "strategy": "retrieval_window",
5870 "retrieval_window": {
5871 "min_recent_user_turns": 4,
5872 "trigger_usage_ratio": 0.82,
5873 "target_usage_ratio": 0.61,
5874 "history_tool_required": true,
5875 "fallback_strategy": "summary"
5876 }
5877 }
5878 });
5879 let root: ConfigRoot = serde_json::from_value(json).unwrap();
5880 let values = ConfigValues::from(root);
5881 assert_eq!(
5882 values.context_management.strategy,
5883 ContextManagementStrategy::RetrievalWindow
5884 );
5885 assert_eq!(
5886 values.context_management.retrieval_window.fallback_strategy,
5887 ContextManagementFallbackStrategy::Summary
5888 );
5889
5890 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5891 assert_eq!(
5892 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
5893 4
5894 );
5895 assert_eq!(
5896 persisted["context_management"]["strategy"],
5897 "retrieval_window"
5898 );
5899 }
5900
5901 #[test]
5902 fn retrieval_window_context_management_rejects_unsafe_policy() {
5903 for invalid in [
5904 serde_json::json!({
5905 "strategy": "retrieval_window",
5906 "retrieval_window": {"min_recent_user_turns": 0}
5907 }),
5908 serde_json::json!({
5909 "strategy": "retrieval_window",
5910 "retrieval_window": {
5911 "target_usage_ratio": 0.8,
5912 "trigger_usage_ratio": 0.8
5913 }
5914 }),
5915 serde_json::json!({
5916 "strategy": "retrieval_window",
5917 "retrieval_window": {
5918 "target_usage_ratio": 0.005,
5919 "trigger_usage_ratio": 0.006
5920 }
5921 }),
5922 serde_json::json!({
5923 "strategy": "retrieval_window",
5924 "retrieval_window": {"history_tool_required": false}
5925 }),
5926 ] {
5927 serde_json::from_value::<ContextManagementConfig>(invalid)
5928 .expect_err("unsafe retrieval-window policy must fail closed");
5929 }
5930 }
5931
5932 #[test]
5933 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
5934 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5935 "provider_type": "openai",
5936 "enabled": true
5937 }))
5938 .unwrap();
5939 let mut config = Config::default();
5940 config.values.provider = "gemini".to_string();
5941 config
5942 .provider_instances
5943 .insert("work".to_string(), instance);
5944 config.default_provider_instance = Some("work".to_string());
5945 config.providers_mut().openai = Some(OpenAIConfig::default());
5946
5947 let json = serde_json::to_value(&config).unwrap();
5948 assert_eq!(json["provider"], "gemini");
5949 assert!(json["providers"]["openai"].is_object());
5950 assert_eq!(json["default_provider_instance"], "work");
5951
5952 let round_trip: Config = serde_json::from_value(json).unwrap();
5953 assert_eq!(round_trip.provider, "gemini");
5954 assert_eq!(
5955 round_trip.default_provider_instance.as_deref(),
5956 Some("work")
5957 );
5958 assert!(round_trip.provider_instances.contains_key("work"));
5959 assert!(round_trip.providers().openai.is_some());
5960 }
5961
5962 #[test]
5963 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
5964 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
5965 let dir = tempfile::tempdir().unwrap();
5966 let mut config = Config::default();
5967 config.values.provider = "anthropic".to_string();
5968 config.provider_instances.insert(
5969 "work".to_string(),
5970 serde_json::from_value(serde_json::json!({
5971 "provider_type": "openai",
5972 "model": "gpt-instance",
5973 "enabled": true,
5974 "future_instance_metadata": "preserved"
5975 }))
5976 .unwrap(),
5977 );
5978 config.default_provider_instance = Some("work".to_string());
5979 config.features.provider_model_ref = true;
5980 config.providers_mut().openai = Some(OpenAIConfig::default());
5981 config.providers_mut().anthropic = Some(AnthropicConfig::default());
5982 config
5983 .providers_mut()
5984 .extra
5985 .insert("provider".to_string(), serde_json::json!("anthropic"));
5986 config.providers_mut().extra.insert(
5987 "future_provider".to_string(),
5988 serde_json::json!({"kept": true}),
5989 );
5990
5991 let (root_bytes, provider_bytes) =
5992 config.prepare_provider_transaction_documents(&[]).unwrap();
5993 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5994 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5995 assert!(root.get("provider").is_none());
5996 assert!(root.get("providers").is_none());
5997 assert_eq!(root["default_provider_instance"], "work");
5998 assert_eq!(
5999 root["provider_instances"]["work"]["future_instance_metadata"],
6000 "preserved"
6001 );
6002 assert!(providers.get("provider").is_none());
6003 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6004 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6005 }
6006 assert_eq!(providers["future_provider"]["kept"], true);
6007
6008 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6009 let saved_root: Value =
6010 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6011 .unwrap();
6012 let saved_providers: Value =
6013 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6014 .unwrap();
6015 assert!(saved_root.get("provider").is_none());
6016 assert!(saved_root.get("providers").is_none());
6017 assert_eq!(saved_root["default_provider_instance"], "work");
6018 assert!(saved_providers.get("openai").is_none());
6019 assert!(saved_providers.get("anthropic").is_none());
6020 assert!(saved_providers.get("provider").is_none());
6021 assert_eq!(saved_providers["future_provider"]["kept"], true);
6022
6023 config.providers_mut().gemini = Some(GeminiConfig::default());
6024 config.save_providers_to_dir(dir.path()).unwrap();
6025 let provider_only: Value =
6026 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6027 .unwrap();
6028 assert!(provider_only.get("gemini").is_none());
6029 assert!(provider_only.get("provider").is_none());
6030 assert_eq!(provider_only["future_provider"]["kept"], true);
6031 }
6032
6033 #[test]
6034 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6035 let mut config = Config::default();
6036 config.values.provider = "openai".to_string();
6037 config.providers_mut().openai = Some(OpenAIConfig {
6038 model: Some("gpt-legacy".to_string()),
6039 ..OpenAIConfig::default()
6040 });
6041 config.provider_instances.insert(
6042 "work".to_string(),
6043 serde_json::from_value(serde_json::json!({
6044 "provider_type": "copilot",
6045 "enabled": true
6046 }))
6047 .unwrap(),
6048 );
6049 config.default_provider_instance = Some("openai".to_string());
6050
6051 let (root_bytes, provider_bytes) =
6052 config.prepare_provider_transaction_documents(&[]).unwrap();
6053 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6054 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6055 assert_eq!(root["provider"], "openai");
6056 assert_eq!(root["default_provider_instance"], "openai");
6057 assert!(root["provider_instances"]["work"].is_object());
6058 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6059 }
6060
6061 #[test]
6062 fn persistence_keeps_legacy_provider_without_instance_default() {
6063 let values = ConfigValues {
6064 provider: "gemini".to_string(),
6065 ..ConfigValues::default()
6066 };
6067
6068 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6069 assert_eq!(json["provider"], "gemini");
6070 }
6071
6072 #[test]
6073 fn stream_timeout_rejects_zero_and_unbounded_values() {
6074 for (field, invalid) in [
6075 ("transport_idle_timeout_secs", 0),
6076 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6077 ("semantic_idle_timeout_secs", 0),
6078 ] {
6079 let mut timeout = serde_json::json!({});
6080 timeout[field] = serde_json::json!(invalid);
6081 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6082 .expect_err("invalid timeout must be rejected");
6083 assert!(error.to_string().contains("stream timeout must be between"));
6084 }
6085 }
6086
6087 struct EnvVarGuard {
6088 key: &'static str,
6089 previous: Option<OsString>,
6090 }
6091
6092 impl EnvVarGuard {
6093 fn set(key: &'static str, value: &str) -> Self {
6094 let previous = std::env::var_os(key);
6095 std::env::set_var(key, value);
6096 Self { key, previous }
6097 }
6098
6099 fn unset(key: &'static str) -> Self {
6100 let previous = std::env::var_os(key);
6101 std::env::remove_var(key);
6102 Self { key, previous }
6103 }
6104 }
6105
6106 impl Drop for EnvVarGuard {
6107 fn drop(&mut self) {
6108 match &self.previous {
6109 Some(value) => std::env::set_var(self.key, value),
6110 None => std::env::remove_var(self.key),
6111 }
6112 }
6113 }
6114
6115 #[test]
6116 fn run_budget_config_merge_is_tighten_only_per_field() {
6117 let config_default = RunBudgetConfig {
6118 max_total_tokens: Some(100_000),
6119 max_tool_calls: Some(500),
6120 max_subagents: Some(10),
6121 max_rounds: Some(200),
6122 };
6123
6124 assert_eq!(
6126 config_default.merged_with_override(None),
6127 config_default,
6128 "no override falls back to the config default entirely"
6129 );
6130
6131 let tighten_one = RunBudgetConfig {
6134 max_total_tokens: Some(5_000),
6135 max_tool_calls: None,
6136 max_subagents: None,
6137 max_rounds: None,
6138 };
6139 let merged = config_default.merged_with_override(Some(&tighten_one));
6140 assert_eq!(merged.max_total_tokens, Some(5_000));
6141 assert_eq!(merged.max_tool_calls, Some(500));
6142 assert_eq!(merged.max_subagents, Some(10));
6143 assert_eq!(merged.max_rounds, Some(200));
6144
6145 let loosen_attempt = RunBudgetConfig {
6148 max_total_tokens: Some(999_999_999),
6149 max_tool_calls: Some(10_000),
6150 max_subagents: Some(1_000),
6151 max_rounds: Some(100_000),
6152 };
6153 assert_eq!(
6154 config_default.merged_with_override(Some(&loosen_attempt)),
6155 config_default,
6156 "looser per-request values must be clamped to the config ceiling"
6157 );
6158
6159 let empty_override = RunBudgetConfig::default();
6163 assert_eq!(
6164 config_default.merged_with_override(Some(&empty_override)),
6165 config_default,
6166 "an all-absent override body keeps every configured ceiling"
6167 );
6168
6169 let unlimited_default = RunBudgetConfig::default();
6173 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6174 assert_eq!(merged.max_total_tokens, Some(5_000));
6175 assert_eq!(merged.max_tool_calls, None);
6176 assert_eq!(merged.max_subagents, None);
6177 assert_eq!(merged.max_rounds, None);
6178
6179 let tighten_rounds = RunBudgetConfig {
6182 max_total_tokens: None,
6183 max_tool_calls: None,
6184 max_subagents: None,
6185 max_rounds: Some(20),
6186 };
6187 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6188 assert_eq!(merged.max_rounds, Some(20));
6189 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6190 assert_eq!(
6191 merged.max_rounds,
6192 Some(20),
6193 "a stricter per-request round cap wins over the config default"
6194 );
6195 }
6196
6197 #[test]
6198 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6199 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6200 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6201 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6202 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6203
6204 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6205 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6206 assert_eq!(cfg.max_total_tokens, Some(250_000));
6207 assert_eq!(
6208 cfg.max_tool_calls, None,
6209 "absent field defaults to unlimited"
6210 );
6211 assert_eq!(
6212 cfg.max_rounds, None,
6213 "absent round cap defaults to unlimited"
6214 );
6215
6216 assert_eq!(cfg.max_subagents, Some(3));
6217
6218 let json = r#"{ "max_rounds": 250 }"#;
6220 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6221 assert_eq!(cfg.max_rounds, Some(250));
6222 let round_tripped: RunBudgetConfig =
6223 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6224 assert_eq!(round_tripped.max_rounds, Some(250));
6225 assert_eq!(round_tripped.max_total_tokens, None);
6226
6227 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6231 assert_eq!(empty, "{}");
6232 }
6233
6234 #[test]
6235 fn subagents_config_without_remote_placements_deserializes_empty() {
6236 let json = r#"{ "max_concurrent": 4 }"#;
6239 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6240 assert_eq!(cfg.max_concurrent, Some(4));
6241 assert!(cfg.remote_placements.is_empty());
6242 let back = serde_json::to_string(&cfg).unwrap();
6244 assert!(
6245 !back.contains("remote_placements"),
6246 "empty vec is skipped: {back}"
6247 );
6248 }
6249
6250 #[test]
6251 fn remote_actor_placement_round_trips() {
6252 let json = r#"{
6253 "remote_placements": [
6254 {
6255 "role": "explorer",
6256 "endpoint": "wss://gpu-host:8443",
6257 "token_env": "WORKER_TOKEN",
6258 "ca_cert_file": "/etc/bamboo/worker.pem"
6259 },
6260 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6261 ]
6262 }"#;
6263 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6264 assert_eq!(cfg.remote_placements.len(), 2);
6265 let p0 = &cfg.remote_placements[0];
6266 assert_eq!(p0.role, "explorer");
6267 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6268 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6269 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6270 let p1 = &cfg.remote_placements[1];
6272 assert_eq!(p1.role, "writer");
6273 assert!(p1.token_env.is_none());
6274 assert!(p1.ca_cert_file.is_none());
6275
6276 let back = serde_json::to_string(&cfg).unwrap();
6277 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6278 assert_eq!(cfg, reparsed, "round-trip is stable");
6279 assert!(!back.contains("\"token_env\":null"));
6280 assert!(!back.contains("\"ca_cert_file\":null"));
6281 }
6282
6283 #[test]
6284 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6285 let json = r#"{ "max_concurrent": 4 }"#;
6288 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6289 assert!(cfg.schedulable_placements.is_empty());
6290 let back = serde_json::to_string(&cfg).unwrap();
6292 assert!(
6293 !back.contains("schedulable_placements"),
6294 "empty vec is skipped: {back}"
6295 );
6296 }
6297
6298 #[test]
6299 fn schedulable_placement_round_trips() {
6300 let json = r#"{
6301 "schedulable_placements": [
6302 {
6303 "role": "explorer",
6304 "pool": "gpu-pool",
6305 "registry_url": "https://control-plane:9562",
6306 "token_env": "WORKER_TOKEN",
6307 "ca_cert_file": "/etc/bamboo/worker.pem"
6308 },
6309 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6310 ]
6311 }"#;
6312 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6313 assert_eq!(cfg.schedulable_placements.len(), 2);
6314 let p0 = &cfg.schedulable_placements[0];
6315 assert_eq!(p0.role, "explorer");
6316 assert_eq!(p0.pool, "gpu-pool");
6317 assert_eq!(p0.registry_url, "https://control-plane:9562");
6318 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6319 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6320 let p1 = &cfg.schedulable_placements[1];
6322 assert_eq!(p1.role, "writer");
6323 assert_eq!(p1.pool, "cpu-pool");
6324 assert!(p1.token_env.is_none());
6325 assert!(p1.ca_cert_file.is_none());
6326
6327 let back = serde_json::to_string(&cfg).unwrap();
6328 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6329 assert_eq!(cfg, reparsed, "round-trip is stable");
6330 assert!(!back.contains("\"token_env\":null"));
6331 assert!(!back.contains("\"ca_cert_file\":null"));
6332 }
6333
6334 #[test]
6335 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6336 let json = r#"{ "max_concurrent": 4 }"#;
6340 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6341 assert!(cfg.mcp_role_allowlist.is_empty());
6342 let back = serde_json::to_string(&cfg).unwrap();
6344 assert!(
6345 !back.contains("mcp_role_allowlist"),
6346 "empty vec is skipped: {back}"
6347 );
6348 }
6349
6350 #[test]
6351 fn mcp_role_allowlist_entry_round_trips() {
6352 let json = r#"{
6353 "mcp_role_allowlist": [
6354 { "role": "researcher", "tools": ["fetch_url"] },
6355 { "role": "sandboxed", "tools": [] }
6356 ]
6357 }"#;
6358 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6359 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6360 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6361 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6362 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6365 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6366
6367 let back = serde_json::to_string(&cfg).unwrap();
6368 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6369 assert_eq!(cfg, reparsed, "round-trip is stable");
6370 }
6371
6372 #[test]
6373 fn server_config_without_tls_field_deserializes_back_compat() {
6374 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6377 "port": 9562,
6378 "bind": "127.0.0.1"
6379 }))
6380 .expect("legacy server config without tls should deserialize");
6381
6382 assert_eq!(server.tls, None);
6383 assert_eq!(server.port, 9562);
6384 assert_eq!(server.bind, "127.0.0.1");
6385 }
6386
6387 #[test]
6388 fn server_config_omits_tls_when_none() {
6389 let server = ServerConfig::default();
6392 let value = serde_json::to_value(&server).expect("server config should serialize");
6393 let obj = value
6394 .as_object()
6395 .expect("server config serializes to object");
6396 assert!(
6397 !obj.contains_key("tls"),
6398 "tls must be omitted when None, got: {value}"
6399 );
6400 }
6401
6402 #[test]
6403 fn server_config_with_tls_roundtrips() {
6404 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6405 "port": 9562,
6406 "bind": "0.0.0.0",
6407 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6408 }))
6409 .expect("server config with tls should deserialize");
6410
6411 let tls = server.tls.clone().expect("tls should be Some");
6412 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6413 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6414
6415 let value = serde_json::to_value(&server).expect("serialize");
6417 assert!(value.as_object().unwrap().contains_key("tls"));
6418 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6419 assert_eq!(back.tls, server.tls);
6420 }
6421
6422 #[test]
6423 fn access_control_without_devices_field_deserializes_back_compat() {
6424 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6427 "password_enabled": true,
6428 "password_hash": "deadbeef",
6429 "password_salt": "01020304",
6430 }))
6431 .expect("legacy access_control without devices should deserialize");
6432
6433 assert!(access.devices.is_empty());
6434 assert!(access.password_enabled);
6435 }
6436
6437 #[test]
6438 fn access_control_omits_devices_when_empty() {
6439 let access = AccessControlConfig {
6442 password_enabled: true,
6443 repair_required: false,
6444 password_hash: Some("deadbeef".to_string()),
6445 password_salt: Some("01020304".to_string()),
6446 password_credential_ref: None,
6447 password_configured: false,
6448 updated_at: None,
6449 devices: Vec::new(),
6450 };
6451 let value = serde_json::to_value(&access).expect("serialize");
6452 let obj = value.as_object().expect("object");
6453 assert!(
6454 !obj.contains_key("devices"),
6455 "devices must be omitted when empty, got: {value}"
6456 );
6457 }
6458
6459 #[test]
6460 fn access_control_with_devices_roundtrips() {
6461 let device = DeviceCredential {
6462 device_id: "bamboo_0123456789ab".to_string(),
6463 label: "iPhone 15".to_string(),
6464 token_hash: "abcd".to_string(),
6465 token_salt: "ef01".to_string(),
6466 token_credential_ref: None,
6467 token_configured: false,
6468 created_at: "2026-06-23T00:00:00Z".to_string(),
6469 last_used_at: None,
6470 revoked: false,
6471 };
6472 let access = AccessControlConfig {
6473 password_enabled: true,
6474 repair_required: false,
6475 password_hash: Some("deadbeef".to_string()),
6476 password_salt: Some("01020304".to_string()),
6477 password_credential_ref: None,
6478 password_configured: false,
6479 updated_at: None,
6480 devices: vec![device.clone()],
6481 };
6482 let value = serde_json::to_value(&access).expect("serialize");
6483 assert!(value.as_object().unwrap().contains_key("devices"));
6484 assert!(value["devices"][0].get("token_hash").is_none());
6485 assert!(value["devices"][0].get("token_salt").is_none());
6486 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6487 assert_eq!(back.devices[0].device_id, device.device_id);
6488 assert!(back.devices[0].token_hash.is_empty());
6489 assert!(back.devices[0].token_salt.is_empty());
6490 }
6491
6492 #[test]
6493 fn reasoning_effort_for_key_resolves_instance_id() {
6494 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6498 "provider_type": "copilot",
6499 "reasoning_effort": "high",
6500 }))
6501 .expect("instance config should deserialize");
6502
6503 let mut config = Config::create_default();
6504 config
6505 .provider_instances
6506 .insert("copilot-work".to_string(), instance);
6507
6508 assert_eq!(
6509 config.reasoning_effort_for_key("copilot-work"),
6510 Some(ReasoningEffort::High),
6511 );
6512 }
6513
6514 #[test]
6515 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6516 let mut config = Config::create_default();
6518 config.providers.bodhi = Some(
6519 serde_json::from_value(serde_json::json!({
6520 "reasoning_effort": "xhigh",
6521 }))
6522 .expect("bodhi config should deserialize"),
6523 );
6524
6525 assert_eq!(
6526 config.reasoning_effort_for_key("bodhi"),
6527 Some(ReasoningEffort::Xhigh),
6528 );
6529 }
6530
6531 #[test]
6532 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6533 let mut config = Config::create_default();
6534 config.providers.openai = Some(
6535 serde_json::from_value(serde_json::json!({
6536 "api_key": "sk-test",
6537 "reasoning_effort": "low",
6538 }))
6539 .expect("openai config should deserialize"),
6540 );
6541
6542 assert_eq!(
6543 config.reasoning_effort_for_key("openai"),
6544 Some(ReasoningEffort::Low),
6545 );
6546 }
6547
6548 #[test]
6549 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6550 let config = Config::create_default();
6551 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6552 assert_eq!(config.reasoning_effort_for_key(" "), None);
6553 }
6554
6555 struct TempHome {
6556 path: PathBuf,
6557 }
6558
6559 impl TempHome {
6560 fn new() -> Self {
6561 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6573 std::sync::atomic::AtomicU64::new(0);
6574 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6575 let nanos = SystemTime::now()
6576 .duration_since(UNIX_EPOCH)
6577 .expect("clock should be after unix epoch")
6578 .as_nanos();
6579 let path = std::env::temp_dir().join(format!(
6580 "chat-core-config-test-{}-{}-{}",
6581 std::process::id(),
6582 nanos,
6583 unique
6584 ));
6585 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6586 Self { path }
6587 }
6588
6589 fn set_config_json(&self, content: &str) {
6590 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6593 std::fs::write(self.path.join("config.json"), content)
6594 .expect("failed to write config.json");
6595 }
6596 }
6597
6598 impl Drop for TempHome {
6599 fn drop(&mut self) {
6600 let _ = std::fs::remove_dir_all(&self.path);
6601 }
6602 }
6603
6604 fn env_lock() -> &'static Mutex<()> {
6608 crate::test_support::env_cache_lock()
6609 }
6610
6611 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6613 env_lock().lock().unwrap_or_else(|poisoned| {
6614 poisoned.into_inner()
6616 })
6617 }
6618
6619 #[test]
6620 fn parse_bool_env_true_values() {
6621 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6622 assert!(parse_bool_env(value), "value {value:?} should be true");
6623 }
6624 }
6625
6626 #[test]
6627 fn parse_bool_env_false_values() {
6628 for value in ["0", "false", "no", "off", "", " "] {
6629 assert!(!parse_bool_env(value), "value {value:?} should be false");
6630 }
6631 }
6632
6633 #[test]
6634 fn config_new_ignores_http_proxy_env_vars() {
6635 let _lock = env_lock_acquire();
6636 let temp_home = TempHome::new();
6637 temp_home.set_config_json(
6638 r#"{
6639 "http_proxy": "",
6640 "https_proxy": ""
6641}"#,
6642 );
6643
6644 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6645 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6646
6647 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6648
6649 assert!(
6650 config.http_proxy.is_empty(),
6651 "config should ignore HTTP_PROXY env var"
6652 );
6653 assert!(
6654 config.https_proxy.is_empty(),
6655 "config should ignore HTTPS_PROXY env var"
6656 );
6657 }
6658
6659 #[test]
6660 fn config_new_loads_config_when_proxy_fields_omitted() {
6661 let _lock = env_lock_acquire();
6662 let temp_home = TempHome::new();
6663 temp_home.set_config_json(
6664 r#"{
6665 "provider": "openai",
6666 "providers": {
6667 "openai": {
6668 "api_key": "sk-test",
6669 "model": "gpt-4o"
6670 }
6671 }
6672}"#,
6673 );
6674
6675 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6676 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6677
6678 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6679
6680 assert_eq!(
6681 config
6682 .providers
6683 .openai
6684 .as_ref()
6685 .and_then(|c| c.model.as_deref()),
6686 Some("gpt-4o"),
6687 "config should load provider model from config file even when proxy fields are omitted"
6688 );
6689 assert!(config.http_proxy.is_empty());
6690 assert!(config.https_proxy.is_empty());
6691 }
6692
6693 #[test]
6694 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6695 let _lock = crate::test_support::env_cache_lock_acquire();
6696 let mut config = Config::default();
6697 config.env_vars.extend([
6698 EnvVarEntry {
6699 name: "SECRET_TOKEN".to_string(),
6700 value: "top-secret".to_string(),
6701 secret: true,
6702 value_encrypted: None,
6703 credential_ref: None,
6704 configured: true,
6705 description: Some("Service token".to_string()),
6706 },
6707 EnvVarEntry {
6708 name: "API_BASE".to_string(),
6709 value: "https://internal.example".to_string(),
6710 secret: false,
6711 value_encrypted: None,
6712 credential_ref: None,
6713 configured: true,
6714 description: Some("Internal API base".to_string()),
6715 },
6716 ]);
6717
6718 config.publish_env_vars();
6719
6720 let injected = Config::current_env_vars();
6721 assert_eq!(
6722 injected.get("SECRET_TOKEN").map(String::as_str),
6723 Some("top-secret")
6724 );
6725 assert_eq!(
6726 injected.get("API_BASE").map(String::as_str),
6727 Some("https://internal.example")
6728 );
6729
6730 let prompt_safe = Config::current_prompt_safe_env_vars();
6731 assert_eq!(prompt_safe.len(), 2);
6732 assert!(prompt_safe.iter().any(|entry| {
6733 entry.name == "SECRET_TOKEN"
6734 && entry.secret
6735 && entry.description.as_deref() == Some("Service token")
6736 }));
6737 assert!(prompt_safe.iter().any(|entry| {
6738 entry.name == "API_BASE"
6739 && !entry.secret
6740 && entry.description.as_deref() == Some("Internal API base")
6741 }));
6742 assert!(!prompt_safe
6743 .iter()
6744 .any(|entry| entry.name.contains("top-secret")));
6745 assert!(!prompt_safe.iter().any(|entry| {
6746 entry
6747 .description
6748 .as_deref()
6749 .is_some_and(|value| value.contains("https://internal.example"))
6750 }));
6751 }
6752
6753 #[test]
6754 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6755 let _lock = crate::test_support::env_cache_lock_acquire();
6756
6757 let mut live = Config::default();
6759 live.env_vars.extend([EnvVarEntry {
6760 name: "BAMBOO_CACHE_OWNER_40".to_string(),
6761 value: "live".to_string(),
6762 secret: false,
6763 value_encrypted: None,
6764 credential_ref: None,
6765 configured: true,
6766 description: None,
6767 }]);
6768 live.publish_env_vars();
6769 assert_eq!(
6770 Config::current_env_vars()
6771 .get("BAMBOO_CACHE_OWNER_40")
6772 .map(String::as_str),
6773 Some("live")
6774 );
6775
6776 let temp = TempHome::new();
6778 temp.set_config_json(
6779 &serde_json::json!({
6780 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6781 })
6782 .to_string(),
6783 );
6784
6785 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6788 assert_eq!(
6789 loaded
6790 .env_vars
6791 .iter()
6792 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6793 .map(|e| e.value.as_str()),
6794 Some("stale-disk"),
6795 "the returned Config holds the disk value"
6796 );
6797 assert_eq!(
6798 Config::current_env_vars()
6799 .get("BAMBOO_CACHE_OWNER_40")
6800 .map(String::as_str),
6801 Some("live"),
6802 "but the global cache is UNTOUCHED — no clobber (#40)"
6803 );
6804
6805 let _ = Config::from_data_dir(Some(temp.path.clone()));
6807 assert_eq!(
6808 Config::current_env_vars()
6809 .get("BAMBOO_CACHE_OWNER_40")
6810 .map(String::as_str),
6811 Some("stale-disk"),
6812 "the publishing loader clobbers the cache (contrast)"
6813 );
6814 }
6815
6816 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6817 std::fs::read_dir(dir)
6818 .unwrap()
6819 .filter_map(|e| e.ok())
6820 .any(|e| {
6821 e.file_name()
6822 .to_string_lossy()
6823 .contains("config.json.corrupted.")
6824 })
6825 }
6826
6827 #[test]
6828 fn corrupt_config_recovered_from_backup_and_quarantined() {
6829 let temp = TempHome::new();
6830 std::fs::write(
6832 temp.path.join("config.json.bak"),
6833 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6834 )
6835 .unwrap();
6836 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6838
6839 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6840 assert_eq!(
6841 config.http_proxy, "http://from-backup",
6842 "recovered from config.json.bak instead of losing all config"
6843 );
6844 assert!(
6845 dir_has_quarantine_file(&temp.path),
6846 "corrupt config.json was quarantined (preserved), not discarded"
6847 );
6848 }
6849
6850 #[test]
6851 fn corrupt_config_without_backup_quarantines_then_defaults() {
6852 let temp = TempHome::new();
6853 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6854
6855 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6856 assert!(
6857 config.http_proxy.is_empty(),
6858 "no backup -> falls back to defaults"
6859 );
6860 assert!(
6861 dir_has_quarantine_file(&temp.path),
6862 "corrupt config.json is quarantined even when there's no backup"
6863 );
6864 }
6865
6866 #[test]
6867 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6868 let temp = TempHome::new();
6869 temp.set_config_json(
6874 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6875 );
6876
6877 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6878 assert_eq!(
6879 config.http_proxy, "http://salvaged",
6880 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
6881 );
6882 assert!(
6883 config.env_vars.is_empty(),
6884 "the corrupt field reset to its default instead of failing the whole load"
6885 );
6886 assert!(
6887 dir_has_quarantine_file(&temp.path),
6888 "the corrupt config.json was still quarantined for inspection"
6889 );
6890 }
6891
6892 #[test]
6893 fn salvage_preferred_over_backup_for_most_recent_intent() {
6894 let temp = TempHome::new();
6895 std::fs::write(
6897 temp.path.join("config.json.bak"),
6898 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
6899 )
6900 .unwrap();
6901 temp.set_config_json(
6903 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
6904 );
6905
6906 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6907 assert_eq!(
6908 config.http_proxy, "http://new-salvaged",
6909 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
6910 );
6911 }
6912
6913 #[test]
6914 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
6915 let temp = TempHome::new();
6916 std::fs::write(
6918 temp.path.join("config.json.bak"),
6919 serde_json::json!({
6920 "http_proxy": "http://old-from-backup",
6921 "https_proxy": "https://kept-from-backup",
6922 })
6923 .to_string(),
6924 )
6925 .unwrap();
6926 temp.set_config_json(
6929 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
6930 );
6931
6932 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6933 assert_eq!(
6935 config.http_proxy, "http://newer-edit",
6936 "the corrupt file's newer valid edit is applied"
6937 );
6938 assert_eq!(
6940 config.https_proxy, "https://kept-from-backup",
6941 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
6942 );
6943 }
6944
6945 #[test]
6946 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
6947 let temp = TempHome::new();
6948 std::fs::write(
6949 temp.path.join("config.json.bak"),
6950 serde_json::json!({
6951 "providers": {
6952 "anthropic": { "model": "claude-backup" }
6953 },
6954 "memory": {
6955 "auto_dream_enabled": true
6956 },
6957 "subagents": {
6958 "claude_code_model": "claude-code-backup"
6959 }
6960 })
6961 .to_string(),
6962 )
6963 .unwrap();
6964 assert!(!temp.path.join("providers.json").exists());
6965 assert!(!temp.path.join("memory.json").exists());
6966 assert!(!temp.path.join("subagents.json").exists());
6967
6968 let (salvaged, recovered_fields) = Config::salvage_partial(
6969 r#"{"providers":"schema-invalid-provider-section"}"#,
6970 &temp.path,
6971 )
6972 .expect("object-shaped corrupt config should be salvageable");
6973
6974 assert!(
6975 recovered_fields.is_empty(),
6976 "the schema-invalid provider field must not replace the backup baseline"
6977 );
6978 assert_eq!(
6979 salvaged
6980 .providers()
6981 .anthropic
6982 .as_ref()
6983 .and_then(|provider| provider.model.as_deref()),
6984 Some("claude-backup")
6985 );
6986 assert!(
6987 salvaged
6988 .memory()
6989 .as_ref()
6990 .expect("backup memory config survives")
6991 .auto_dream_enabled
6992 );
6993 assert_eq!(
6994 salvaged.subagents().claude_code_model.as_deref(),
6995 Some("claude-code-backup")
6996 );
6997 }
6998
6999 #[test]
7000 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7001 let temp = TempHome::new();
7002 std::fs::write(
7005 temp.path.join("config.json.bak"),
7006 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7007 )
7008 .unwrap();
7009 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7010
7011 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7012 assert_eq!(
7013 config.http_proxy, "http://from-backup",
7014 "garbage (non-object) config skips salvage and recovers from .bak"
7015 );
7016 }
7017
7018 #[test]
7019 fn quarantine_files_are_capped_to_newest_n() {
7020 let temp = TempHome::new();
7021 let config_path = temp.path.join("config.json");
7022 std::fs::write(&config_path, "{}").unwrap();
7023
7024 for _ in 0..(QUARANTINE_KEEP + 3) {
7027 quarantine_corrupt_config(&config_path);
7028 std::thread::sleep(std::time::Duration::from_millis(3));
7029 }
7030
7031 let count = std::fs::read_dir(&temp.path)
7032 .unwrap()
7033 .filter_map(|e| e.ok())
7034 .filter(|e| {
7035 e.file_name()
7036 .to_string_lossy()
7037 .starts_with("config.json.corrupted.")
7038 })
7039 .count();
7040 assert_eq!(
7041 count, QUARANTINE_KEEP,
7042 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7043 );
7044 }
7045
7046 #[test]
7047 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7048 let temp = TempHome::new();
7049 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7051 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7052 std::fs::write(
7053 temp.path.join("config.json.bak.1"),
7054 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7055 )
7056 .unwrap();
7057
7058 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7059 assert_eq!(
7060 config.http_proxy, "http://from-gen-1",
7061 "recovered from .bak.1 when both config.json and .bak are corrupt"
7062 );
7063 }
7064
7065 #[test]
7066 fn save_rotates_backup_generations() {
7067 let temp = TempHome::new();
7068 let path = temp.path.join("config.json");
7069 std::fs::write(
7071 &path,
7072 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7073 )
7074 .unwrap();
7075
7076 let mut cfg = Config::create_default();
7077 cfg.http_proxy = "http://proxy-v2".to_string();
7079 cfg.save_to_dir(temp.path.clone()).unwrap();
7080 cfg.http_proxy = "http://proxy-v3".to_string();
7082 cfg.save_to_dir(temp.path.clone()).unwrap();
7083
7084 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7085 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7086 assert!(
7087 bak.contains("proxy-v2"),
7088 ".bak holds the previous generation (v2)"
7089 );
7090 assert!(
7091 bak1.contains("proxy-v1"),
7092 ".bak.1 holds the older rotated generation (v1)"
7093 );
7094 }
7095
7096 #[test]
7097 fn save_backs_up_existing_config() {
7098 let temp = TempHome::new();
7099 std::fs::write(
7101 temp.path.join("config.json"),
7102 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7103 )
7104 .unwrap();
7105
7106 let mut config = Config::create_default();
7107 config.http_proxy = "http://new".to_string();
7108 config
7109 .save_to_dir(temp.path.clone())
7110 .expect("save succeeds");
7111
7112 let backup =
7113 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7114 assert!(
7115 backup.contains("http://old"),
7116 "config.json.bak holds the PREVIOUS config (last-known-good)"
7117 );
7118 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7119 assert!(
7120 current.contains("http://new"),
7121 "config.json holds the new config"
7122 );
7123 }
7124
7125 #[test]
7126 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7127 let temp = TempHome::new();
7128 std::fs::write(
7130 temp.path.join("config.json.bak"),
7131 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7132 )
7133 .unwrap();
7134 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7137
7138 let mut config = Config::create_default();
7139 config.http_proxy = "http://new".to_string();
7140 config
7141 .save_to_dir(temp.path.clone())
7142 .expect("save succeeds");
7143
7144 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7146 assert!(
7147 backup.contains("http://good-bak"),
7148 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7149 );
7150 }
7151
7152 #[test]
7155 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7156 let temp = TempHome::new();
7157 std::fs::write(
7158 temp.path.join("config.json.bak"),
7159 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7160 )
7161 .unwrap();
7162 let corrupt_bytes = "{ not valid json ";
7163 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7164
7165 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7166 let status = config
7167 .recovery_status()
7168 .expect("a corrupt load must set a pending recovery status");
7169 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7170 assert_eq!(
7171 status.source,
7172 ConfigRecoverySource::Backup { generation: 0 },
7173 "recovered from generation-0 (.bak)"
7174 );
7175 let quarantine_path = status
7176 .quarantine_path
7177 .as_ref()
7178 .expect("quarantine copy should have succeeded");
7179 assert_eq!(
7180 std::fs::read_to_string(quarantine_path).unwrap(),
7181 corrupt_bytes,
7182 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7183 );
7184 assert_eq!(
7185 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7186 corrupt_bytes,
7187 "the original config.json itself is untouched by the load (only copied, not moved)"
7188 );
7189 }
7190
7191 #[test]
7192 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7193 let temp = TempHome::new();
7194 temp.set_config_json(
7195 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7196 );
7197
7198 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7199 let status = config.recovery_status().expect("pending recovery");
7200 assert!(!status.confirmed);
7201 match &status.source {
7202 ConfigRecoverySource::Salvaged { fields } => {
7203 assert!(
7204 fields.iter().any(|f| f == "http_proxy"),
7205 "salvaged fields should list the recovered key: {fields:?}"
7206 );
7207 }
7208 other => panic!("expected Salvaged source, got {other:?}"),
7209 }
7210 }
7211
7212 #[test]
7213 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7214 let temp = TempHome::new();
7215 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7217
7218 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7219 let status = config.recovery_status().expect("pending recovery");
7220 assert!(!status.confirmed);
7221 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7222 }
7223
7224 #[test]
7225 fn clean_load_never_sets_recovery_status() {
7226 let temp = TempHome::new();
7227 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7228
7229 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7230 assert!(
7231 config.recovery_status().is_none(),
7232 "a config.json that parses cleanly must never carry a pending recovery status"
7233 );
7234 }
7235
7236 #[test]
7237 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7238 let temp = TempHome::new();
7239 let corrupt_bytes = "}}} broken";
7240 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7241
7242 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7243 assert!(config.recovery_status().is_some());
7244
7245 let err = config
7246 .save_to_dir(temp.path.clone())
7247 .expect_err("save must refuse while recovery is unconfirmed");
7248 assert!(
7249 err.to_string().contains("recovered from corruption")
7250 || err.to_string().contains("confirm"),
7251 "error should explain the refused overwrite: {err}"
7252 );
7253
7254 assert_eq!(
7257 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7258 corrupt_bytes,
7259 "a refused save must leave the corrupt original untouched"
7260 );
7261 }
7262
7263 #[test]
7264 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7265 let temp = TempHome::new();
7266 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7268 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7269
7270 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7271 let status = config.recovery_status().expect("pending recovery");
7272 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7273 assert_eq!(
7274 std::fs::read_to_string(quarantine_path).unwrap(),
7275 truncated,
7276 "truncated original preserved byte for byte in quarantine"
7277 );
7278
7279 let err = config.save_to_dir(temp.path.clone());
7280 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7281 assert_eq!(
7282 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7283 truncated,
7284 "the half-written original stays exactly as it was after a refused save"
7285 );
7286 }
7287
7288 #[test]
7289 fn confirm_recovery_allows_the_next_save() {
7290 let temp = TempHome::new();
7291 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7292
7293 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7294 assert!(config.recovery_status().is_some());
7295
7296 config.confirm_recovery();
7297 assert!(
7298 config.recovery_status().is_some_and(|s| s.confirmed),
7299 "confirm_recovery flips the flag but keeps the status around"
7300 );
7301
7302 config
7303 .save_to_dir(temp.path.clone())
7304 .expect("save must succeed once the recovery is confirmed");
7305 }
7306
7307 #[test]
7308 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7309 let temp = TempHome::new();
7310 std::fs::write(
7311 temp.path.join("config.json"),
7312 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7313 )
7314 .unwrap();
7315
7316 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7317 assert!(config.recovery_status().is_some());
7318 let quarantine_path = config
7319 .recovery_status()
7320 .unwrap()
7321 .quarantine_path
7322 .clone()
7323 .unwrap();
7324
7325 config
7326 .confirm_recovery_and_save_to_dir(temp.path.clone())
7327 .expect("confirm+save should succeed");
7328
7329 assert!(
7330 config.recovery_status().is_none(),
7331 "the pending flag is cleared once the recovery is confirmed and persisted"
7332 );
7333 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7334 assert!(
7335 on_disk.contains("http://recovered"),
7336 "config.json now holds the recovered (salvaged) state"
7337 );
7338 assert!(
7341 quarantine_path.exists(),
7342 "the quarantined original survives confirmation — it's never deleted"
7343 );
7344 }
7345
7346 #[test]
7347 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7348 let temp = TempHome::new();
7349 let mut config = Config::create_default();
7350 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7351 assert!(
7352 err.is_err(),
7353 "confirming a recovery that was never pending must error, not silently succeed"
7354 );
7355 }
7356
7357 fn connect_platform_with_encrypted(
7360 platform_type: &str,
7361 token_encrypted: &str,
7362 ) -> ConnectPlatformConfig {
7363 ConnectPlatformConfig {
7364 id: None,
7365 project_id: None,
7366 platform_type: platform_type.to_string(),
7367 token: None,
7368 token_encrypted: Some(token_encrypted.to_string()),
7369 token_credential_ref: None,
7370 token_configured: false,
7371 app_id: None,
7372 app_secret: None,
7373 app_secret_encrypted: None,
7374 app_secret_credential_ref: None,
7375 app_secret_configured: false,
7376 domain: None,
7377 allow_from: vec!["user-1".to_string()],
7378 admin_from: Vec::new(),
7379 }
7380 }
7381
7382 fn connect_json_path(temp: &TempHome) -> PathBuf {
7383 temp.path.join("connect.json")
7384 }
7385
7386 #[test]
7387 fn save_splits_connect_into_sibling_connect_json() {
7388 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7389 let temp = TempHome::new();
7390
7391 let mut config = Config::create_default();
7392 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7393 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7394
7395 config
7396 .save_to_dir(temp.path.clone())
7397 .expect("save succeeds");
7398
7399 let config_json: serde_json::Value =
7400 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7401 .unwrap();
7402 assert!(
7403 config_json.get("connect").is_none(),
7404 "config.json must not carry the `connect` key after a save"
7405 );
7406
7407 let connect_json: serde_json::Value =
7408 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7409 .unwrap();
7410 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7411 assert!(
7412 connect_json["platforms"][0]["token_encrypted"]
7413 .as_str()
7414 .is_some_and(|v| !v.is_empty()),
7415 "the token is persisted in its encrypted form in connect.json"
7416 );
7417 assert!(
7418 connect_json["platforms"][0].get("token").is_none(),
7419 "the plaintext token is never persisted (skip_serializing)"
7420 );
7421 }
7422
7423 #[test]
7426 fn save_assigns_a_missing_connect_platform_id() {
7427 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7428 let temp = TempHome::new();
7429
7430 let mut config = Config::create_default();
7431 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7432 assert!(
7433 config.connect.platforms[0].id.is_none(),
7434 "precondition: the entry starts without an id"
7435 );
7436
7437 config
7438 .save_to_dir(temp.path.clone())
7439 .expect("save succeeds");
7440
7441 let connect_json: serde_json::Value =
7442 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7443 .unwrap();
7444 let persisted_id = connect_json["platforms"][0]["id"]
7445 .as_str()
7446 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7447 assert!(!persisted_id.is_empty());
7448
7449 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7450 assert_eq!(
7451 reloaded.connect.platforms[0].id.as_deref(),
7452 Some(persisted_id),
7453 "the assigned id round-trips through a reload"
7454 );
7455 }
7456
7457 #[test]
7458 fn save_never_reassigns_an_existing_connect_platform_id() {
7459 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7460 let temp = TempHome::new();
7461
7462 let mut config = Config::create_default();
7463 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7464 platform.id = Some("stable-id-123".to_string());
7465 config.connect.platforms = vec![platform];
7466
7467 config
7468 .save_to_dir(temp.path.clone())
7469 .expect("first save succeeds");
7470 config
7472 .save_to_dir(temp.path.clone())
7473 .expect("second save succeeds");
7474
7475 let connect_json: serde_json::Value =
7476 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7477 .unwrap();
7478 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7479 }
7480
7481 #[test]
7482 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7483 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7484 let temp = TempHome::new();
7485
7486 let mut config = Config::create_default();
7487 config.connect.platforms = vec![
7488 connect_platform_with_encrypted("telegram", "cipher-a"),
7489 connect_platform_with_encrypted("telegram", "cipher-b"),
7490 ];
7491
7492 config
7493 .save_to_dir(temp.path.clone())
7494 .expect("save succeeds");
7495
7496 let connect_json: serde_json::Value =
7497 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7498 .unwrap();
7499 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7500 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7501 assert_ne!(
7502 id_a, id_b,
7503 "two entries sharing platform_type must still get distinct ids"
7504 );
7505 }
7506
7507 #[test]
7508 fn load_never_assigns_or_persists_an_id_by_itself() {
7509 let temp = TempHome::new();
7510 std::fs::write(
7511 connect_json_path(&temp),
7512 serde_json::json!({
7513 "platforms": [
7514 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7515 ]
7516 })
7517 .to_string(),
7518 )
7519 .unwrap();
7520 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7521
7522 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7523
7524 assert!(
7525 config.connect.platforms[0].id.is_none(),
7526 "load alone must not backfill an id in memory"
7527 );
7528 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7529 assert_eq!(
7530 connect_json_before, connect_json_after,
7531 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7532 );
7533 }
7534
7535 #[test]
7536 fn load_merges_connect_json_into_config() {
7537 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7538 let temp = TempHome::new();
7539 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7540 std::fs::write(
7541 connect_json_path(&temp),
7542 serde_json::json!({
7543 "platforms": [
7544 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7545 ]
7546 })
7547 .to_string(),
7548 )
7549 .unwrap();
7550
7551 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7552 assert_eq!(config.connect.platforms.len(), 1);
7553 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7554 assert_eq!(
7555 config.connect.platforms[0].token.as_deref(),
7556 Some("connect-secret")
7557 );
7558 assert!(config.connect.platforms[0].token_encrypted.is_none());
7559 }
7560
7561 #[test]
7562 fn load_without_connect_json_yields_empty_inert_connect_config() {
7563 let temp = TempHome::new();
7564 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7565
7566 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7567 assert!(
7568 config.connect.platforms.is_empty(),
7569 "no connect.json and no legacy key -> empty/inert connect config"
7570 );
7571 assert!(
7572 !connect_json_path(&temp).exists(),
7573 "load must not create connect.json when there is nothing to migrate"
7574 );
7575 }
7576
7577 #[test]
7578 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7579 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7580 let temp = TempHome::new();
7581 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7582 let legacy_cipher_for_assert = legacy_cipher.clone();
7583 temp.set_config_json(
7585 &serde_json::json!({
7586 "http_proxy": "http://keep-me",
7587 "connect": {
7588 "platforms": [
7589 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7590 ]
7591 }
7592 })
7593 .to_string(),
7594 );
7595
7596 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7597
7598 assert_eq!(config.connect.platforms.len(), 1);
7600 assert_eq!(
7601 config.connect.platforms[0].token.as_deref(),
7602 Some("legacy-connect-secret")
7603 );
7604 assert_eq!(config.http_proxy, "http://keep-me");
7606
7607 assert!(
7609 connect_json_path(&temp).exists(),
7610 "migration proactively creates connect.json"
7611 );
7612 let connect_json: serde_json::Value =
7613 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7614 .unwrap();
7615 assert_eq!(
7616 connect_json["platforms"][0]["token_encrypted"],
7617 legacy_cipher_for_assert
7618 );
7619
7620 let config_json: serde_json::Value =
7622 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7623 .unwrap();
7624 assert!(
7625 config_json.get("connect").is_none(),
7626 "config.json is rewritten without the legacy `connect` key"
7627 );
7628 }
7629
7630 #[test]
7638 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7639 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7640 let temp = TempHome::new();
7641
7642 let original_api_key_encrypted =
7643 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7644 temp.set_config_json(
7645 &serde_json::json!({
7646 "providers": {
7647 "openai": {
7648 "api_key_encrypted": original_api_key_encrypted,
7649 }
7650 },
7651 "connect": {
7652 "platforms": [
7653 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7654 ]
7655 }
7656 })
7657 .to_string(),
7658 );
7659
7660 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7661 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7662
7663 assert!(
7666 !temp.path.join("config.json.bak").exists(),
7667 "a read-only load migrating a legacy `connect` key must not rotate \
7668 config.json backups"
7669 );
7670
7671 let config_json: serde_json::Value =
7676 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7677 .unwrap();
7678 assert_eq!(
7679 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7680 "an unrelated secret's ciphertext must not be touched by the connect \
7681 migration's narrow write"
7682 );
7683 assert!(
7684 config_json.get("connect").is_none(),
7685 "config.json is still rewritten without the legacy `connect` key"
7686 );
7687 }
7688
7689 #[test]
7690 fn both_files_present_connect_json_wins() {
7691 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7692 let temp = TempHome::new();
7693 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7694 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7695 temp.set_config_json(
7696 &serde_json::json!({
7697 "connect": {
7698 "platforms": [
7699 { "type": "telegram", "token_encrypted": stale }
7700 ]
7701 }
7702 })
7703 .to_string(),
7704 );
7705 std::fs::write(
7706 connect_json_path(&temp),
7707 serde_json::json!({
7708 "platforms": [
7709 { "type": "telegram", "token_encrypted": authoritative }
7710 ]
7711 })
7712 .to_string(),
7713 )
7714 .unwrap();
7715
7716 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7717 assert_eq!(
7718 config.connect.platforms[0].token.as_deref(),
7719 Some("authoritative-secret"),
7720 "connect.json wins over a stale legacy config.json key"
7721 );
7722 }
7723
7724 #[test]
7729 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7730 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7731 let temp = TempHome::new();
7732 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7733 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7734 temp.set_config_json(
7735 &serde_json::json!({
7736 "http_proxy": "http://keep-me",
7737 "connect": {
7738 "platforms": [
7739 { "type": "telegram", "token_encrypted": stale }
7740 ]
7741 }
7742 })
7743 .to_string(),
7744 );
7745 std::fs::write(
7746 connect_json_path(&temp),
7747 serde_json::json!({
7748 "platforms": [
7749 { "type": "telegram", "token_encrypted": authoritative }
7750 ]
7751 })
7752 .to_string(),
7753 )
7754 .unwrap();
7755
7756 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7757 assert_eq!(
7758 config.connect.platforms[0].token.as_deref(),
7759 Some("authoritative-secret")
7760 );
7761 assert_eq!(config.http_proxy, "http://keep-me");
7763
7764 let config_json: serde_json::Value =
7765 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7766 .unwrap();
7767 assert!(
7768 config_json.get("connect").is_none(),
7769 "the stale legacy `connect` key must be stripped from config.json \
7770 immediately on load, not left for the next natural save"
7771 );
7772 }
7773
7774 #[test]
7782 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7783 let temp = TempHome::new();
7784 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7785 std::fs::write(
7786 temp.path.join("config.json.bak"),
7787 serde_json::json!({
7788 "http_proxy": "http://old",
7789 "connect": {
7790 "platforms": [
7791 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7792 ]
7793 }
7794 })
7795 .to_string(),
7796 )
7797 .unwrap();
7798
7799 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7800 assert!(config.connect.platforms.is_empty());
7803
7804 let bak: serde_json::Value = serde_json::from_str(
7805 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7806 )
7807 .unwrap();
7808 assert!(
7809 bak.get("connect").is_none(),
7810 "the legacy `connect` key must be stripped from the tainted .bak generation"
7811 );
7812 assert_eq!(
7813 bak["http_proxy"], "http://old",
7814 "every other key in the .bak generation survives the scrub byte-for-byte in content"
7815 );
7816 }
7817
7818 #[test]
7823 fn scrub_reaches_all_rotated_generations() {
7824 let temp = TempHome::new();
7825 temp.set_config_json(&serde_json::json!({}).to_string());
7826 for (gen_suffix, cipher) in [
7827 ("config.json.bak", "cipher-gen0"),
7828 ("config.json.bak.1", "cipher-gen1"),
7829 ("config.json.bak.2", "cipher-gen2"),
7830 ] {
7831 std::fs::write(
7832 temp.path.join(gen_suffix),
7833 serde_json::json!({
7834 "connect": {
7835 "platforms": [
7836 { "type": "telegram", "token_encrypted": cipher }
7837 ]
7838 }
7839 })
7840 .to_string(),
7841 )
7842 .unwrap();
7843 }
7844
7845 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7846
7847 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7848 let value: serde_json::Value =
7849 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7850 .unwrap();
7851 assert!(
7852 value.get("connect").is_none(),
7853 "{gen_suffix} must have its legacy `connect` key stripped"
7854 );
7855 }
7856 }
7857
7858 #[test]
7866 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7867 let temp = TempHome::new();
7868 temp.set_config_json(&serde_json::json!({}).to_string());
7869 let bak_path = temp.path.join("config.json.bak");
7870 std::fs::write(
7871 &bak_path,
7872 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7873 )
7874 .unwrap();
7875
7876 let before_bytes = std::fs::read(&bak_path).unwrap();
7877 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7878
7879 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7883
7884 let after_bytes = std::fs::read(&bak_path).unwrap();
7885 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7886 assert_eq!(
7887 before_bytes, after_bytes,
7888 "a .bak generation without a legacy `connect` key must not be rewritten at all"
7889 );
7890 assert_eq!(
7891 before_mtime, after_mtime,
7892 "no write means no mtime change either"
7893 );
7894 }
7895
7896 #[test]
7901 fn scrub_skips_unparsable_backup_without_deleting_it() {
7902 let temp = TempHome::new();
7903 temp.set_config_json(&serde_json::json!({}).to_string());
7904 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
7905
7906 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7907
7908 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7909 assert_eq!(
7910 content, "{ not valid json",
7911 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
7912 );
7913 }
7914
7915 #[test]
7919 fn scrub_tolerates_missing_generations() {
7920 let temp = TempHome::new();
7921 temp.set_config_json(&serde_json::json!({}).to_string());
7922 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7924 assert!(config.connect.platforms.is_empty());
7925 assert!(!temp.path.join("config.json.bak").exists());
7926 }
7927
7928 #[test]
7931 fn scrub_does_not_break_backup_rotation() {
7932 let temp = TempHome::new();
7933 std::fs::write(
7934 temp.path.join("config.json"),
7935 serde_json::json!({
7936 "http_proxy": "http://proxy-v1",
7937 "connect": {
7938 "platforms": [
7939 { "type": "telegram", "token_encrypted": "legacy-cipher" }
7940 ]
7941 }
7942 })
7943 .to_string(),
7944 )
7945 .unwrap();
7946 std::fs::write(
7947 temp.path.join("config.json.bak"),
7948 serde_json::json!({
7949 "http_proxy": "http://proxy-v0",
7950 "connect": {
7951 "platforms": [
7952 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
7953 ]
7954 }
7955 })
7956 .to_string(),
7957 )
7958 .unwrap();
7959
7960 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7962 let bak: serde_json::Value = serde_json::from_str(
7963 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7964 )
7965 .unwrap();
7966 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
7967
7968 config.http_proxy = "http://proxy-v2".to_string();
7971 config.save_to_dir(temp.path.clone()).unwrap();
7972
7973 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7974 assert!(
7975 new_bak.contains("proxy-v1"),
7976 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
7977 );
7978 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7979 assert!(
7980 new_bak1.contains("proxy-v0"),
7981 ".bak.1 holds the scrubbed older generation after rotation"
7982 );
7983 assert!(
7984 !new_bak1.contains("legacy-bak-cipher"),
7985 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
7986 stripped secret"
7987 );
7988 }
7989
7990 #[test]
7991 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
7992 let temp = TempHome::new();
7993 temp.set_config_json("{}");
7994 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7995
7996 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7997 assert!(
7998 config.connect.platforms.is_empty(),
7999 "corrupt connect.json fails SAFE to an empty/inert connect config"
8000 );
8001
8002 let backup = connect_json_path(&temp).with_extension("json.bak");
8003 assert!(
8004 backup.exists(),
8005 "the corrupt connect.json is quarantined to connect.json.bak"
8006 );
8007 assert!(
8008 std::fs::read_to_string(backup)
8009 .unwrap()
8010 .contains("not valid json"),
8011 "the quarantined copy holds the bad content"
8012 );
8013 assert!(
8018 !connect_json_path(&temp).exists(),
8019 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8020 connect.json should remain after quarantine"
8021 );
8022 }
8023
8024 #[test]
8025 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8026 let temp = TempHome::new();
8027 temp.set_config_json(
8030 &serde_json::json!({
8031 "connect": {
8032 "platforms": [
8033 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8034 ]
8035 }
8036 })
8037 .to_string(),
8038 );
8039 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8040
8041 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8042 assert!(
8043 config.connect.platforms.is_empty(),
8044 "corrupt connect.json must not fall back to the legacy config.json copy"
8045 );
8046 }
8047
8048 #[test]
8049 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8050 let temp = TempHome::new();
8051 let config = Config::create_default();
8052 assert!(config.connect.platforms.is_empty());
8053
8054 config
8055 .save_to_dir(temp.path.clone())
8056 .expect("save succeeds");
8057
8058 assert!(
8059 !connect_json_path(&temp).exists(),
8060 "an empty connect config with no pre-existing file must not create one"
8061 );
8062 }
8063
8064 #[test]
8065 fn connect_json_backed_up_before_overwrite() {
8066 let temp = TempHome::new();
8067 std::fs::write(
8068 connect_json_path(&temp),
8069 serde_json::json!({
8070 "platforms": [
8071 { "type": "telegram", "token_encrypted": "old-cipher" }
8072 ]
8073 })
8074 .to_string(),
8075 )
8076 .unwrap();
8077
8078 let mut config = Config::create_default();
8079 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8080 config
8081 .save_to_dir(temp.path.clone())
8082 .expect("save succeeds");
8083
8084 let backup = connect_json_path(&temp).with_extension("json.bak");
8085 assert!(
8086 std::fs::read_to_string(backup)
8087 .unwrap()
8088 .contains("old-cipher"),
8089 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8090 );
8091 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8092 assert!(current.contains("new-cipher"));
8093 }
8094
8095 #[test]
8098 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8099 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8100 let temp = TempHome::new();
8101
8102 let mut config = Config::create_default();
8103 config.connect.platforms = vec![ConnectPlatformConfig {
8104 id: None,
8105 project_id: None,
8106 platform_type: "feishu".to_string(),
8107 token: None,
8108 token_encrypted: None,
8109 token_credential_ref: None,
8110 token_configured: false,
8111 app_id: Some("cli_real_app_id".to_string()),
8112 app_secret: Some("plain-app-secret".to_string()),
8113 app_secret_encrypted: None,
8114 app_secret_credential_ref: None,
8115 app_secret_configured: false,
8116 domain: Some("lark".to_string()),
8117 allow_from: vec!["ou_1".to_string()],
8118 admin_from: Vec::new(),
8119 }];
8120
8121 config
8122 .save_to_dir(temp.path.clone())
8123 .expect("save succeeds");
8124
8125 let connect_json: serde_json::Value =
8126 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8127 .unwrap();
8128 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8129 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8130 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8131 assert!(
8132 connect_json["platforms"][0]["app_secret_encrypted"]
8133 .as_str()
8134 .is_some_and(|v| !v.is_empty()),
8135 "app_secret is persisted in its encrypted form in connect.json"
8136 );
8137 assert!(
8138 connect_json["platforms"][0].get("app_secret").is_none(),
8139 "the plaintext app_secret is never persisted (skip_serializing)"
8140 );
8141 }
8142
8143 #[test]
8144 fn load_hydrates_feishu_app_secret_from_encrypted() {
8145 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8146 let temp = TempHome::new();
8147
8148 let mut config = Config::create_default();
8149 config.connect.platforms = vec![ConnectPlatformConfig {
8150 id: None,
8151 project_id: None,
8152 platform_type: "feishu".to_string(),
8153 token: None,
8154 token_encrypted: None,
8155 token_credential_ref: None,
8156 token_configured: false,
8157 app_id: Some("cli_real_app_id".to_string()),
8158 app_secret: Some("plain-app-secret".to_string()),
8159 app_secret_encrypted: None,
8160 app_secret_credential_ref: None,
8161 app_secret_configured: false,
8162 domain: Some("lark".to_string()),
8163 allow_from: vec!["ou_1".to_string()],
8164 admin_from: Vec::new(),
8165 }];
8166 config
8167 .save_to_dir(temp.path.clone())
8168 .expect("save succeeds");
8169
8170 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8171 assert_eq!(reloaded.connect.platforms.len(), 1);
8172 assert_eq!(
8173 reloaded.connect.platforms[0].app_secret.as_deref(),
8174 Some("plain-app-secret"),
8175 "reload hydrates app_secret from app_secret_encrypted"
8176 );
8177 assert_eq!(
8178 reloaded.connect.platforms[0].app_id.as_deref(),
8179 Some("cli_real_app_id")
8180 );
8181 assert_eq!(
8182 reloaded.connect.platforms[0].domain.as_deref(),
8183 Some("lark")
8184 );
8185 }
8186
8187 #[test]
8188 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8189 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8190 let temp = TempHome::new();
8191 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8192 std::fs::write(
8193 connect_json_path(&temp),
8194 serde_json::json!({
8195 "platforms": [
8196 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8197 ]
8198 })
8199 .to_string(),
8200 )
8201 .unwrap();
8202
8203 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8204
8205 assert_eq!(config.connect.platforms.len(), 1);
8206 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8207 assert_eq!(
8208 config.connect.platforms[0].token.as_deref(),
8209 Some("legacy-telegram-secret")
8210 );
8211 assert_eq!(
8212 config.connect.platforms[0].app_id, None,
8213 "a legacy entry with no Feishu fields deserializes them as None"
8214 );
8215 assert_eq!(config.connect.platforms[0].app_secret, None);
8216 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8217 assert_eq!(config.connect.platforms[0].domain, None);
8218 }
8219
8220 #[test]
8221 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8222 let _lock = env_lock_acquire();
8223 let temp_home = TempHome::new();
8224 temp_home.set_config_json(
8225 r#"{
8226 "provider": "openai",
8227 "providers": {
8228 "openai": {
8229 "api_key": "sk-test",
8230 "model": "gpt-4o"
8231 }
8232 }
8233}"#,
8234 );
8235
8236 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8237 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8238
8239 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8240
8241 assert_eq!(
8242 config
8243 .providers
8244 .openai
8245 .as_ref()
8246 .and_then(|c| c.model.as_deref()),
8247 Some("gpt-4o")
8248 );
8249 assert!(
8250 config.http_proxy.is_empty(),
8251 "config should keep http_proxy empty when field is omitted"
8252 );
8253 assert!(
8254 config.https_proxy.is_empty(),
8255 "config should keep https_proxy empty when field is omitted"
8256 );
8257 }
8258
8259 #[test]
8260 fn get_memory_background_model_prefers_memory_specific_override() {
8261 let mut config = Config::default();
8262 config.features.provider_model_ref = false;
8263 config.provider = "openai".to_string();
8264 config.providers.openai = Some(OpenAIConfig {
8265 api_key: "test".to_string(),
8266 api_key_encrypted: None,
8267 credential_ref: None,
8268 base_url: None,
8269 model: Some("gpt-main".to_string()),
8270 fast_model: Some("gpt-fast".to_string()),
8271 vision_model: None,
8272 reasoning_effort: None,
8273 responses_only_models: vec![],
8274 request_overrides: None,
8275 extra: BTreeMap::new(),
8276 api_key_from_env: false,
8277 });
8278 config.memory.0 = Some(MemoryConfig {
8279 background_model: Some("memory-fast".to_string()),
8280 ..MemoryConfig::default()
8281 });
8282
8283 assert_eq!(
8284 config.get_memory_background_model().as_deref(),
8285 Some("memory-fast")
8286 );
8287 }
8288
8289 #[test]
8290 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8291 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8296 api_key: api_key.to_string(),
8297 api_key_encrypted: None,
8298 credential_ref: None,
8299 base_url: None,
8300 model: None,
8301 fast_model: None,
8302 vision_model: None,
8303 reasoning_effort: None,
8304 responses_only_models: vec![],
8305 request_overrides: None,
8306 extra: BTreeMap::new(),
8307 api_key_from_env: from_env,
8308 };
8309
8310 let mut current = Config::default();
8312 current.providers.openai = Some(openai("sk-env", true));
8313 let mut merged = Config::default();
8314 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8316 let got = merged.providers.openai.as_ref().unwrap();
8317 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8318 assert!(got.api_key_from_env, "env flag restored");
8319
8320 let mut merged = Config::default();
8322 merged.providers.openai = Some(openai("sk-explicit", false));
8323 merged.preserve_env_sourced_provider_keys(¤t);
8324 assert_eq!(
8325 merged.providers.openai.as_ref().unwrap().api_key,
8326 "sk-explicit",
8327 "explicit patch key must win"
8328 );
8329
8330 let mut current_plain = Config::default();
8332 current_plain.providers.openai = Some(openai("sk-plain", false));
8333 let mut merged = Config::default();
8334 merged.providers.openai = Some(openai("", false));
8335 merged.preserve_env_sourced_provider_keys(¤t_plain);
8336 assert!(
8337 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8338 "non-env key must not be restored by this path"
8339 );
8340 }
8341
8342 #[test]
8343 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8344 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8348 api_key: api_key.to_string(),
8349 api_key_encrypted: enc.map(str::to_string),
8350 credential_ref: None,
8351 base_url: None,
8352 model: None,
8353 fast_model: None,
8354 vision_model: None,
8355 reasoning_effort: None,
8356 responses_only_models: vec![],
8357 request_overrides: None,
8358 extra: BTreeMap::new(),
8359 api_key_from_env: false,
8360 };
8361
8362 let mut config = Config::default();
8364 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8365 config
8366 .refresh_provider_api_keys_encrypted()
8367 .expect("refresh");
8368 assert_eq!(
8369 config
8370 .providers
8371 .openai
8372 .as_ref()
8373 .unwrap()
8374 .api_key_encrypted
8375 .as_deref(),
8376 Some("preexisting-ciphertext"),
8377 "existing ciphertext must be preserved when plaintext is empty"
8378 );
8379
8380 let mut config = Config::default();
8382 config.providers.openai = Some(openai("", None));
8383 config
8384 .refresh_provider_api_keys_encrypted()
8385 .expect("refresh");
8386 assert!(
8387 config
8388 .providers
8389 .openai
8390 .as_ref()
8391 .unwrap()
8392 .api_key_encrypted
8393 .is_none(),
8394 "no key + no ciphertext should stay None"
8395 );
8396
8397 let mut config = Config::default();
8399 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8400 config
8401 .refresh_provider_api_keys_encrypted()
8402 .expect("refresh");
8403 let enc = config
8404 .providers
8405 .openai
8406 .as_ref()
8407 .unwrap()
8408 .api_key_encrypted
8409 .clone()
8410 .expect("ciphertext present");
8411 assert!(
8412 !enc.is_empty() && enc != "stale-ciphertext",
8413 "plaintext re-encrypted"
8414 );
8415 }
8416
8417 #[test]
8418 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8419 let mut config = Config::default();
8425 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8426 "provider_type": "openai",
8427 "api_key": "sk-instance-live",
8428 }))
8429 .expect("valid instance");
8430 config
8431 .provider_instances
8432 .insert("work".to_string(), instance);
8433
8434 config.refresh_encrypted_secrets().expect("refresh");
8435 assert!(
8436 config.provider_instances["work"]
8437 .api_key_encrypted
8438 .is_some(),
8439 "live config must hold ciphertext after refresh"
8440 );
8441
8442 let value = serde_json::to_value(&config).expect("serialize");
8444 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8445 assert!(
8446 back.provider_instances["work"].api_key.is_empty(),
8447 "plaintext is skip_serializing"
8448 );
8449 back.hydrate_provider_instance_api_keys_from_encrypted();
8450 assert_eq!(
8451 back.provider_instances["work"].api_key, "sk-instance-live",
8452 "key must be recoverable from the round-tripped ciphertext"
8453 );
8454 }
8455
8456 #[test]
8457 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8458 let mut config = Config::default();
8459 config.features.provider_model_ref = false;
8460 config.provider = "openai".to_string();
8461 config.providers.openai = Some(OpenAIConfig {
8462 api_key: "test".to_string(),
8463 api_key_encrypted: None,
8464 credential_ref: None,
8465 base_url: None,
8466 model: Some("gpt-main".to_string()),
8467 fast_model: Some("gpt-fast".to_string()),
8468 vision_model: None,
8469 reasoning_effort: None,
8470 responses_only_models: vec![],
8471 request_overrides: None,
8472 extra: BTreeMap::new(),
8473 api_key_from_env: false,
8474 });
8475
8476 assert_eq!(
8477 config.get_memory_background_model().as_deref(),
8478 Some("gpt-fast")
8479 );
8480 }
8481
8482 #[test]
8483 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8484 let mut config = Config::default();
8485 config.features.provider_model_ref = false;
8486 config.provider = "openai".to_string();
8487 config.providers.openai = Some(OpenAIConfig {
8488 api_key: "sk-stale".to_string(),
8489 model: Some("legacy-main".to_string()),
8490 fast_model: Some("legacy-fast".to_string()),
8491 vision_model: Some("legacy-vision".to_string()),
8492 reasoning_effort: Some(ReasoningEffort::Low),
8493 ..OpenAIConfig::default()
8494 });
8495 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8496 "provider_type": "openai",
8497 "model": "instance-main",
8498 "fast_model": "instance-fast",
8499 "vision_model": "instance-vision",
8500 "reasoning_effort": "high",
8501 "enabled": true
8502 }))
8503 .unwrap();
8504 instance.api_key = "sk-instance".to_string();
8505 config
8506 .provider_instances
8507 .insert("work".to_string(), instance);
8508 config.default_provider_instance = Some("work".to_string());
8509
8510 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8511 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8512 assert_eq!(
8513 config.get_memory_background_model().as_deref(),
8514 Some("instance-fast")
8515 );
8516 assert_eq!(
8517 config.get_task_summary_model().as_deref(),
8518 Some("instance-fast")
8519 );
8520 assert_eq!(
8521 config.get_vision_model().as_deref(),
8522 Some("instance-vision")
8523 );
8524 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8525 }
8526
8527 #[test]
8528 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8529 let mut config = Config::default();
8530 config.features.provider_model_ref = true;
8531 config.provider_instances.insert(
8532 "compatibility-default".to_string(),
8533 serde_json::from_value(serde_json::json!({
8534 "provider_type": "openai",
8535 "reasoning_effort": "low",
8536 "enabled": true
8537 }))
8538 .unwrap(),
8539 );
8540 config.provider_instances.insert(
8541 "chat-provider".to_string(),
8542 serde_json::from_value(serde_json::json!({
8543 "provider_type": "anthropic",
8544 "reasoning_effort": "high",
8545 "enabled": true
8546 }))
8547 .unwrap(),
8548 );
8549 config.default_provider_instance = Some("compatibility-default".to_string());
8550 config.defaults = Some(
8551 serde_json::from_value(serde_json::json!({
8552 "chat": { "provider": "chat-provider", "model": "claude" }
8553 }))
8554 .unwrap(),
8555 );
8556
8557 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8558
8559 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8560 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8561 }
8562
8563 #[test]
8564 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8565 let _provider =
8566 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8567 let _openai_key = crate::test_support::override_runtime_env_var(
8568 "BAMBOO_OPENAI_API_KEY",
8569 Some("sk-runtime-env-instance"),
8570 );
8571 let _anthropic_key =
8572 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8573 let _gemini_key =
8574 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8575
8576 let mut config = Config::default();
8577 *config.providers_mut() = ProviderConfigs::default();
8578 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8579 let mut extra = serde_json::Map::new();
8580 if from_environment {
8581 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8582 }
8583 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8584 extra.insert("enabled".to_string(), serde_json::json!(true));
8585 config.provider_instances.insert(
8586 id.to_string(),
8587 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8588 );
8589 }
8590
8591 config.apply_runtime_env_overrides();
8592
8593 assert_eq!(
8594 config.default_provider_instance.as_deref(),
8595 Some("a-marked"),
8596 "type override selects the lexicographically first enabled instance"
8597 );
8598 assert_eq!(
8599 config.provider_instances["a-marked"].api_key,
8600 "sk-runtime-env-instance"
8601 );
8602 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8603 assert!(
8604 config.providers().openai.is_none(),
8605 "instance-mode env hydration must not recreate a legacy alias"
8606 );
8607
8608 config.refresh_encrypted_secrets().unwrap();
8609 let serialized = serde_json::to_string(&config).unwrap();
8610 assert!(!serialized.contains("sk-runtime-env-instance"));
8611 assert!(
8612 config.provider_instances["a-marked"]
8613 .api_key_encrypted
8614 .is_none(),
8615 "runtime env plaintext must not be encrypted into ordinary config"
8616 );
8617 }
8618
8619 #[test]
8620 fn runtime_provider_override_prefers_exact_instance_id() {
8621 let _provider =
8622 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8623 let _openai_key =
8624 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8625 let _anthropic_key =
8626 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8627 let _gemini_key =
8628 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8629 let mut config = Config::default();
8630 for id in ["work", "personal"] {
8631 config.provider_instances.insert(
8632 id.to_string(),
8633 serde_json::from_value(serde_json::json!({
8634 "provider_type": "openai",
8635 "enabled": true
8636 }))
8637 .unwrap(),
8638 );
8639 }
8640
8641 config.apply_runtime_env_overrides();
8642 assert_eq!(
8643 config.default_provider_instance.as_deref(),
8644 Some("personal")
8645 );
8646 }
8647
8648 #[test]
8649 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
8650 let mut config = Config::default();
8651 config.features.provider_model_ref = false;
8652 config.provider = "openai".to_string();
8653 config.providers.openai = Some(OpenAIConfig {
8654 api_key: "test".to_string(),
8655 api_key_encrypted: None,
8656 credential_ref: None,
8657 base_url: None,
8658 model: Some("gpt-main".to_string()),
8659 fast_model: None,
8660 vision_model: None,
8661 reasoning_effort: None,
8662 responses_only_models: vec![],
8663 request_overrides: None,
8664 extra: BTreeMap::new(),
8665 api_key_from_env: false,
8666 });
8667
8668 assert_eq!(
8669 config.get_memory_background_model().as_deref(),
8670 Some("gpt-main")
8671 );
8672 }
8673
8674 #[test]
8675 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8676 let legacy = serde_json::json!({
8677 "memory": MemoryConfig {
8678 background_model: Some("dream-fast".to_string()),
8679 summary_target_ratio: 0.20,
8680 summary_safe_window_percent: 80,
8681 auto_dream_enabled: true,
8682 auto_dream_interval_secs: 900,
8683 project_prompt_injection: false,
8684 relevant_recall: false,
8685 relevant_recall_rerank: true,
8686 project_first_dream: false,
8687 ledger_agenda_injection: false,
8688 ledger_gardener_enabled: false,
8689 ledger_gardener_interval_secs: 7_200,
8690 ledger_distillation_enabled: false,
8691 dream_refine_mode: true,
8692 gardener_enabled: true,
8693 gardener_interval_secs: 3_600,
8694 gardener_volume_trigger: 40,
8695 gardener_max_splits_per_run: 4,
8696 gardener_min_sections: 7,
8697 dedup_gardener_enabled: true,
8698 dedup_gardener_min_score: 0.7,
8699 dedup_gardener_max_merges_per_run: 3,
8700 memory_active_capacity: 500,
8701 capacity_max_archivals_per_run: 10,
8702 granularity_freshness_gardener_enabled: false,
8703 }
8704 });
8705 let config: Config = serde_json::from_value(legacy).unwrap();
8706
8707 let serialized = serde_json::to_value(&config).expect("config should serialize");
8708 assert!(serialized.get("memory").is_some());
8709 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8710 assert!(round_tripped
8711 .memory()
8712 .as_ref()
8713 .is_some_and(|memory| memory.dream_refine_mode));
8714 let memory = config.memory.as_ref().expect("memory config should exist");
8715 assert!(memory.auto_dream_enabled);
8716 assert!(!memory.project_prompt_injection);
8717 assert!(!memory.relevant_recall);
8718 assert!(memory.relevant_recall_rerank);
8719 assert!(!memory.project_first_dream);
8720 assert!(memory.dream_refine_mode);
8721 assert!(memory.gardener_enabled);
8722 assert_eq!(memory.gardener_interval_secs, 3_600);
8723 assert_eq!(memory.gardener_volume_trigger, 40);
8724 assert_eq!(memory.gardener_max_splits_per_run, 4);
8725 assert_eq!(memory.gardener_min_sections, 7);
8726 assert!(memory.dedup_gardener_enabled);
8727 assert_eq!(memory.dedup_gardener_min_score, 0.7);
8728 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8729 assert_eq!(memory.memory_active_capacity, 500);
8730 assert_eq!(memory.capacity_max_archivals_per_run, 10);
8731 assert!(!memory.granularity_freshness_gardener_enabled);
8732 }
8733
8734 #[test]
8736 fn memory_active_capacity_defaults_off() {
8737 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8738 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8739 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8740 let memory = parsed.memory.as_ref().unwrap();
8741 assert_eq!(memory.memory_active_capacity, 0);
8742 assert_eq!(
8743 memory.capacity_max_archivals_per_run, 50,
8744 "omitted field takes the serde default fn"
8745 );
8746 }
8747
8748 #[test]
8749 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8750 let defaults = MemoryConfig::default();
8751 assert_eq!(defaults.summary_target_ratio, 0.20);
8752 assert_eq!(defaults.summary_safe_window_percent, 80);
8753
8754 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8755 let memory = parsed.memory.as_ref().expect("memory present");
8756 assert_eq!(memory.summary_target_ratio, 0.20);
8757 assert_eq!(memory.summary_safe_window_percent, 80);
8758 }
8759
8760 #[test]
8764 fn memory_maintenance_integrators_default_on() {
8765 let defaults = MemoryConfig::default();
8766 assert!(defaults.auto_dream_enabled);
8767 assert!(defaults.gardener_enabled);
8768 assert!(defaults.dedup_gardener_enabled);
8769 assert_eq!(defaults.gardener_volume_trigger, 25);
8770
8771 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8773 let memory = parsed.memory.as_ref().expect("memory present");
8774 assert!(
8775 memory.auto_dream_enabled,
8776 "auto_dream on when field omitted"
8777 );
8778 assert!(memory.gardener_enabled, "gardener on when field omitted");
8779 assert!(
8780 memory.dedup_gardener_enabled,
8781 "dedup gardener on when field omitted"
8782 );
8783 let opted_out: Config =
8785 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8786 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8787 }
8788
8789 #[test]
8790 fn memory_config_env_overrides_prompt_flags() {
8791 let _lock = env_lock_acquire();
8792 let temp_home = TempHome::new();
8793 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8794 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8795 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8796 let _relevant_recall_rerank =
8797 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8798 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8799
8800 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8801 let memory = config
8802 .memory
8803 .as_ref()
8804 .expect("memory config should be created by env overrides");
8805 assert!(!memory.project_prompt_injection);
8806 assert!(!memory.relevant_recall);
8807 assert!(memory.relevant_recall_rerank);
8808 assert!(!memory.project_first_dream);
8809 }
8810
8811 #[test]
8812 fn provider_api_keys_injected_from_env_and_never_persisted() {
8813 let _lock = env_lock_acquire();
8814 let temp_home = TempHome::new();
8815 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8816 let _anthropic = crate::test_support::override_runtime_env_var(
8817 "BAMBOO_ANTHROPIC_API_KEY",
8818 Some("sk-ant-from-env"),
8819 );
8820 let _openai = crate::test_support::override_runtime_env_var(
8821 "BAMBOO_OPENAI_API_KEY",
8822 Some("sk-oai-from-env"),
8823 );
8824 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8825
8826 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8829 assert_eq!(
8830 config
8831 .providers
8832 .anthropic
8833 .as_ref()
8834 .expect("anthropic created from env")
8835 .api_key,
8836 "sk-ant-from-env"
8837 );
8838 assert_eq!(
8839 config
8840 .providers
8841 .openai
8842 .as_ref()
8843 .expect("openai created from env")
8844 .api_key,
8845 "sk-oai-from-env"
8846 );
8847 assert!(config.providers.gemini.is_none());
8849
8850 config
8856 .save_to_dir(temp_home.path.clone())
8857 .expect("save config");
8858 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8859 .expect("read persisted config.json");
8860 assert!(
8861 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8862 "env key must not be persisted as plaintext"
8863 );
8864 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8865 assert!(
8866 disk_json["providers"]["anthropic"]
8867 .get("api_key_encrypted")
8868 .is_none(),
8869 "env-sourced anthropic key must not be re-encrypted into config.json"
8870 );
8871 assert!(
8872 disk_json["providers"]["openai"]
8873 .get("api_key_encrypted")
8874 .is_none(),
8875 "env-sourced openai key must not be re-encrypted into config.json"
8876 );
8877
8878 drop(_anthropic);
8881 drop(_openai);
8882 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
8883 assert!(reloaded
8884 .providers
8885 .anthropic
8886 .as_ref()
8887 .map(|a| a.api_key.is_empty())
8888 .unwrap_or(true));
8889 }
8890
8891 #[test]
8892 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
8893 let _lock = env_lock_acquire();
8894 let temp_home = TempHome::new();
8895 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8896 let target = temp_home.path.join("workspace-default");
8897 std::fs::create_dir_all(&target).expect("default work area dir should exist");
8898
8899 let mut config = Config::default();
8900 config.default_work_area.replace(DefaultWorkAreaConfig {
8901 path: Some("~/workspace-default".to_string()),
8902 });
8903
8904 assert_eq!(config.get_default_work_area_path(), Some(target));
8905 }
8906
8907 #[test]
8908 fn get_default_work_area_path_returns_none_for_missing_directory() {
8909 let _lock = env_lock_acquire();
8910 let temp_home = TempHome::new();
8911 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8912
8913 let mut config = Config::default();
8914 config.default_work_area.replace(DefaultWorkAreaConfig {
8915 path: Some("~/missing-default-work-area".to_string()),
8916 });
8917
8918 assert!(config.get_default_work_area_path().is_none());
8919 }
8920
8921 #[test]
8922 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
8923 let mut config = Config::default();
8924 config.tools.disabled = vec![
8925 " read_file ".to_string(),
8926 "".to_string(),
8927 "read_file".to_string(),
8928 "bash".to_string(),
8929 "default::getCurrentDir".to_string(),
8930 "default::applyPatch".to_string(),
8931 "default::custom_tool".to_string(),
8932 "mcp__alpha__inspect".to_string(),
8933 ];
8934
8935 config.normalize_tool_settings();
8936
8937 assert_eq!(
8938 config.tools.disabled,
8939 vec![
8940 "bash",
8941 "default::applyPatch",
8942 "default::custom_tool",
8943 "default::getCurrentDir",
8944 "mcp__alpha__inspect",
8945 "read_file"
8946 ]
8947 );
8948 }
8949
8950 #[test]
8951 fn config_load_preserves_disabled_references_for_catalog_resolution() {
8952 let _lock = env_lock_acquire();
8953 let temp_home = TempHome::new();
8954 temp_home.set_config_json(
8955 r#"{
8956 "tools": {
8957 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
8958 }
8959}"#,
8960 );
8961
8962 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8963 assert_eq!(
8964 config.tools.disabled,
8965 vec!["bash", "default::getCurrentDir", "read_file"]
8966 );
8967 assert!(config.disabled_tool_references().contains("bash"));
8968 assert!(config.disabled_tool_references().contains("read_file"));
8969 assert!(config
8970 .disabled_tool_references()
8971 .contains("default::getCurrentDir"));
8972 assert_eq!(
8973 config.disabled_tool_names(),
8974 BTreeSet::from([
8975 "Bash".to_string(),
8976 "GetCurrentDir".to_string(),
8977 "Read".to_string()
8978 ])
8979 );
8980 }
8981
8982 #[test]
8983 fn normalize_skill_settings_trims_dedupes_and_sorts() {
8984 let mut config = Config::default();
8985 config.skills.disabled = vec![
8986 " pdf ".to_string(),
8987 "".to_string(),
8988 "pdf".to_string(),
8989 "skill-creator".to_string(),
8990 ];
8991
8992 config.normalize_skill_settings();
8993
8994 assert_eq!(
8995 config.skills.disabled,
8996 vec!["pdf".to_string(), "skill-creator".to_string()]
8997 );
8998 }
8999
9000 #[test]
9001 fn config_load_reads_disabled_skills_as_normalized_ids() {
9002 let _lock = env_lock_acquire();
9003 let temp_home = TempHome::new();
9004 temp_home.set_config_json(
9005 r#"{
9006 "skills": {
9007 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9008 }
9009}"#,
9010 );
9011
9012 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9013 assert_eq!(
9014 config.skills.disabled,
9015 vec!["pdf".to_string(), "skill-creator".to_string()]
9016 );
9017 assert!(config.disabled_skill_ids().contains("pdf"));
9018 assert!(config.disabled_skill_ids().contains("skill-creator"));
9019 }
9020
9021 #[test]
9022 fn test_server_config_defaults() {
9023 let _lock = env_lock_acquire();
9024 let temp_home = TempHome::new();
9025
9026 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9027 assert_eq!(config.server.port, 9562);
9028 assert_eq!(config.server.bind, "127.0.0.1");
9029 assert_eq!(config.server.workers, 10);
9030 assert!(config.server.static_dir.is_none());
9031 }
9032
9033 #[test]
9034 fn test_server_addr() {
9035 let mut config = Config::default();
9036 config.server.port = 9000;
9037 config.server.bind = "0.0.0.0".to_string();
9038 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9039 }
9040
9041 #[test]
9042 fn test_env_var_overrides() {
9043 let _lock = env_lock_acquire();
9044 let temp_home = TempHome::new();
9045
9046 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9047 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9048 let _provider =
9049 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9050
9051 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9052 assert_eq!(config.server.port, 9999);
9053 assert_eq!(config.server.bind, "192.168.1.1");
9054 assert_eq!(config.provider, "openai");
9055 }
9056
9057 #[test]
9058 fn test_config_save_and_load() {
9059 let _lock = env_lock_acquire();
9060 let temp_home = TempHome::new();
9061
9062 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9063 config.server.port = 9000;
9064 config.server.bind = "0.0.0.0".to_string();
9065 config.provider = "anthropic".to_string();
9066
9067 config
9069 .save_to_dir(temp_home.path.clone())
9070 .expect("Failed to save config");
9071
9072 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9074
9075 assert_eq!(loaded.server.port, 9000);
9077 assert_eq!(loaded.server.bind, "0.0.0.0");
9078 assert_eq!(loaded.provider, "anthropic");
9079 }
9080
9081 #[test]
9082 fn modular_root_and_public_serde_preserve_legacy_shape() {
9083 let _lock = env_lock_acquire();
9084 let temp_home = TempHome::new();
9085 let input = serde_json::json!({
9086 "http_proxy": "http://proxy.example",
9087 "provider": "openai",
9088 "mcp": { "servers": [] },
9089 "future_extension": { "enabled": true },
9090 "memory": { "background_model": "memory-model" },
9091 "subagents": { "max_concurrent": 7 },
9092 "providers": { "openai": { "model": "chat-model" } }
9093 });
9094
9095 let config: Config = serde_json::from_value(input).unwrap();
9096 let public = serde_json::to_value(&config).unwrap();
9097 assert_eq!(public["http_proxy"], "http://proxy.example");
9098 assert!(public.get("mcp").is_none());
9099 assert!(public.get("mcpServers").is_some());
9100 assert_eq!(public["future_extension"]["enabled"], true);
9101 assert_eq!(public["memory"]["background_model"], "memory-model");
9102 assert_eq!(public["subagents"]["max_concurrent"], 7);
9103 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9104
9105 let round_tripped: Config = serde_json::from_value(public).unwrap();
9106 assert_eq!(
9107 round_tripped
9108 .memory()
9109 .as_ref()
9110 .unwrap()
9111 .background_model
9112 .as_deref(),
9113 Some("memory-model")
9114 );
9115 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9116 assert_eq!(
9117 round_tripped
9118 .providers()
9119 .openai
9120 .as_ref()
9121 .unwrap()
9122 .model
9123 .as_deref(),
9124 Some("chat-model")
9125 );
9126 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9127
9128 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9129 let persisted: Value =
9130 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9131 .unwrap();
9132 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9133 assert!(persisted.get("mcpServers").is_some());
9134 assert_eq!(persisted["future_extension"]["enabled"], true);
9135 assert!(persisted.get("memory").is_none());
9136 assert!(persisted.get("subagents").is_none());
9137 assert!(persisted.get("providers").is_none());
9138 for internal_section_name in [
9139 "network",
9140 "provider_routing",
9141 "model_behavior",
9142 "tooling",
9143 "workspace",
9144 "execution",
9145 "integrations",
9146 "plugin_security",
9147 ] {
9148 assert!(persisted.get(internal_section_name).is_none());
9149 }
9150 }
9151
9152 #[test]
9153 fn config_decrypts_proxy_auth_from_encrypted_field() {
9154 let _lock = env_lock_acquire();
9155 let temp_home = TempHome::new();
9156
9157 let key_guard = crate::encryption::set_test_encryption_key([
9159 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9160 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9161 0x1c, 0x1d, 0x1e, 0x1f,
9162 ]);
9163
9164 let auth = ProxyAuth {
9165 username: "user".to_string(),
9166 password: "pass".to_string(),
9167 };
9168 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9169 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9170
9171 temp_home.set_config_json(&format!(
9172 r#"{{
9173 "http_proxy": "http://proxy.example.com:8080",
9174 "proxy_auth_encrypted": "{encrypted}"
9175}}"#
9176 ));
9177 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9178 let loaded_auth = config
9179 .proxy_auth
9180 .as_ref()
9181 .expect("proxy auth should be hydrated");
9182 assert_eq!(loaded_auth.username, "user");
9183 assert_eq!(loaded_auth.password, "pass");
9184 drop(key_guard);
9185 }
9186
9187 #[test]
9188 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9189 let _lock = env_lock_acquire();
9190 let temp_home = TempHome::new();
9191
9192 let key_guard = crate::encryption::set_test_encryption_key([
9194 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9195 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9196 0x1c, 0x1d, 0x1e, 0x1f,
9197 ]);
9198
9199 let auth = ProxyAuth {
9200 username: "user".to_string(),
9201 password: "pass".to_string(),
9202 };
9203 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9204 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9205
9206 temp_home.set_config_json(&format!(
9208 r#"{{
9209 "http_proxy": "http://proxy.example.com:8080",
9210 "http_proxy_auth_encrypted": "{encrypted}",
9211 "https_proxy_auth_encrypted": "{encrypted}"
9212}}"#
9213 ));
9214
9215 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9216 let loaded_auth = config
9217 .proxy_auth
9218 .as_ref()
9219 .expect("proxy auth should be hydrated");
9220 assert_eq!(loaded_auth.username, "user");
9221 assert_eq!(loaded_auth.password, "pass");
9222 drop(key_guard);
9223 }
9224
9225 #[test]
9226 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9227 let _lock = env_lock_acquire();
9228 let temp_home = TempHome::new();
9229
9230 let key_guard = crate::encryption::set_test_encryption_key([
9232 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9233 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9234 0x1c, 0x1d, 0x1e, 0x1f,
9235 ]);
9236
9237 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9238 config.proxy_auth = Some(ProxyAuth {
9239 username: "user".to_string(),
9240 password: "pass".to_string(),
9241 });
9242 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9243 assert!(error
9244 .to_string()
9245 .contains("isolated credential transaction"));
9246 let path = temp_home.path.join("config.json");
9247 assert!(
9248 !path.exists(),
9249 "a rejected unisolated secret must not create config.json"
9250 );
9251 drop(key_guard);
9252 }
9253
9254 #[test]
9255 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9256 let _lock = env_lock_acquire();
9257 let temp_home = TempHome::new();
9258 let mut config = Config::default();
9259 config.env_vars.push(EnvVarEntry {
9260 name: "TOKEN".to_string(),
9261 value: String::new(),
9262 secret: true,
9263 value_encrypted: None,
9264 credential_ref: None,
9265 configured: true,
9266 description: None,
9267 });
9268 let path = temp_home.path.join("config.json");
9269
9270 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9271 assert!(error
9272 .to_string()
9273 .contains("isolated credential transaction"));
9274 assert!(
9275 !path.exists(),
9276 "a rejected dangling ref must not create config.json"
9277 );
9278
9279 let original = br#"{"preserve":"original"}"#;
9280 std::fs::write(&path, original).unwrap();
9281 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9282 assert_eq!(
9283 std::fs::read(&path).unwrap(),
9284 original,
9285 "a rejected dangling ref must not modify an existing config.json"
9286 );
9287 }
9288
9289 #[test]
9290 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9291 let _lock = env_lock_acquire();
9292 let temp_home = TempHome::new();
9293
9294 let key_guard = crate::encryption::set_test_encryption_key([
9296 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9297 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9298 0x1c, 0x1d, 0x1e, 0x1f,
9299 ]);
9300
9301 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9302 crate::CredentialStore::open(&temp_home.path)
9303 .replace(
9304 reference.clone(),
9305 "sk-test-provider-key",
9306 crate::CredentialSource::User,
9307 0,
9308 )
9309 .unwrap();
9310 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9311 config.provider = "openai".to_string();
9312 config.providers.openai = Some(OpenAIConfig {
9313 api_key: "sk-test-provider-key".to_string(),
9314 api_key_encrypted: None,
9315 credential_ref: Some(reference),
9316 base_url: None,
9317 model: None,
9318 fast_model: None,
9319 vision_model: None,
9320 reasoning_effort: None,
9321 responses_only_models: vec![],
9322 request_overrides: None,
9323 extra: Default::default(),
9324 api_key_from_env: false,
9325 });
9326
9327 config
9328 .save_to_dir(temp_home.path.clone())
9329 .expect("save should persist provider reference");
9330
9331 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9332 .expect("read providers.json");
9333 assert!(
9334 !content.contains("api_key_encrypted"),
9335 "providers.json must not store provider ciphertext"
9336 );
9337 assert!(
9338 !content.contains("\"api_key\""),
9339 "providers.json should not store plaintext provider keys"
9340 );
9341
9342 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9343 let openai = loaded
9344 .providers
9345 .openai
9346 .as_ref()
9347 .expect("openai config should be present");
9348 assert_eq!(openai.api_key, "sk-test-provider-key");
9349
9350 drop(key_guard);
9351 }
9352
9353 #[test]
9354 fn config_save_persists_mcp_servers_in_mainstream_format() {
9355 let _lock = env_lock_acquire();
9356 let temp_home = TempHome::new();
9357
9358 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9359
9360 let mut env = std::collections::HashMap::new();
9361 env.insert("TOKEN".to_string(), "supersecret".to_string());
9362
9363 config.mcp.servers = vec![
9364 bamboo_domain::mcp_config::McpServerConfig {
9365 id: "stdio-secret".to_string(),
9366 name: None,
9367 enabled: true,
9368 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9369 bamboo_domain::mcp_config::StdioConfig {
9370 command: "echo".to_string(),
9371 args: vec![],
9372 cwd: None,
9373 env,
9374 env_encrypted: std::collections::HashMap::new(),
9375 env_credential_refs: std::collections::HashMap::new(),
9376 startup_timeout_ms: 5000,
9377 },
9378 ),
9379 request_timeout_ms: 5000,
9380 healthcheck_interval_ms: 1000,
9381 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9382 allowed_tools: vec![],
9383 denied_tools: vec![],
9384 },
9385 bamboo_domain::mcp_config::McpServerConfig {
9386 id: "http-secret".to_string(),
9387 name: None,
9388 enabled: true,
9389 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9390 bamboo_domain::mcp_config::StreamableHttpConfig {
9391 url: "http://localhost:8080/http".to_string(),
9392 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9393 name: "Authorization".to_string(),
9394 value: "Bearer token123".to_string(),
9395 value_encrypted: None,
9396 credential_ref: None,
9397 }],
9398 connect_timeout_ms: 5000,
9399 },
9400 ),
9401 request_timeout_ms: 5000,
9402 healthcheck_interval_ms: 1000,
9403 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9404 allowed_tools: vec![],
9405 denied_tools: vec![],
9406 },
9407 ];
9408
9409 config
9410 .save_to_dir(temp_home.path.clone())
9411 .expect("save should persist MCP servers");
9412
9413 let content =
9414 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9415 assert!(
9416 content.contains("\"mcpServers\""),
9417 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9418 );
9419 assert!(
9420 content.contains("supersecret"),
9421 "config.json should persist MCP stdio env in mainstream format"
9422 );
9423 assert!(
9424 content.contains("Bearer token123"),
9425 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9426 );
9427 assert!(
9428 !content.contains("\"env_encrypted\""),
9429 "config.json should not persist legacy env_encrypted fields"
9430 );
9431 assert!(
9432 !content.contains("\"value_encrypted\""),
9433 "config.json should not persist legacy value_encrypted fields"
9434 );
9435
9436 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9437 let stdio = loaded
9438 .mcp
9439 .servers
9440 .iter()
9441 .find(|s| s.id == "stdio-secret")
9442 .expect("stdio server should exist");
9443 match &stdio.transport {
9444 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9445 assert_eq!(
9446 stdio.env.get("TOKEN").map(|s| s.as_str()),
9447 Some("supersecret")
9448 );
9449 }
9450 _ => panic!("Expected stdio transport"),
9451 }
9452
9453 let http = loaded
9454 .mcp
9455 .servers
9456 .iter()
9457 .find(|s| s.id == "http-secret")
9458 .expect("http server should exist");
9459 match &http.transport {
9460 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9461 assert_eq!(http.headers[0].value, "Bearer token123");
9462 }
9463 _ => panic!("Expected Streamable HTTP transport"),
9464 }
9465 }
9466
9467 #[test]
9468 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9469 let _lock = env_lock_acquire();
9470 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9471 let temp_home = TempHome::new();
9472 let env_plaintext = "mcp-root-env-plaintext-597";
9473 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9474 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9475 std::fs::write(
9476 temp_home.path.join("config.json"),
9477 serde_json::to_vec_pretty(&serde_json::json!({
9478 "features": {"provider_model_ref": true},
9479 "mcpServers": {
9480 "stdio-root": {
9481 "command": "unused-disabled-command",
9482 "env": {"TOKEN": env_plaintext},
9483 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9484 "env_credential_refs": {
9485 "TOKEN": "mcp.stdio-root.env_TOKEN"
9486 }
9487 },
9488 "http-root": {
9489 "url": "https://example.test/mcp",
9490 "transport_kind": "streamable_http",
9491 "headers": {"Authorization": header_plaintext},
9492 "header_credential_refs": {
9493 "Authorization": "mcp.http-root.header_Authorization"
9494 }
9495 }
9496 }
9497 }))
9498 .unwrap(),
9499 )
9500 .unwrap();
9501 std::fs::write(
9502 temp_home.path.join("mcp.json"),
9503 serde_json::to_vec_pretty(&serde_json::json!({
9504 "schema_version": 1,
9505 "revision": 7,
9506 "data": {
9507 "stdio-root": {
9508 "command": "unused-disabled-command",
9509 "enabled": false,
9510 "env_encrypted": {"TOKEN": legacy_ciphertext},
9511 "request_timeout_ms": 100,
9512 "healthcheck_interval_ms": 100
9513 },
9514 "http-root": {
9515 "url": "https://example.test/mcp",
9516 "transport_kind": "streamable_http",
9517 "enabled": false,
9518 "headers": {"Authorization": header_plaintext},
9519 "request_timeout_ms": 100,
9520 "healthcheck_interval_ms": 100
9521 }
9522 }
9523 }))
9524 .unwrap(),
9525 )
9526 .unwrap();
9527
9528 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9529 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9530 temp_home.path.join("mcp.json"),
9531 1,
9532 )
9533 .load()
9534 .unwrap()
9535 .unwrap();
9536 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9537 config.mcp = stored.data;
9538 config
9539 .hydrate_mcp_credentials_from_store(&temp_home.path)
9540 .unwrap();
9541
9542 let public = serde_json::to_value(&config.mcp).unwrap();
9543 let rendered_public = public.to_string();
9544 assert!(rendered_public.contains(env_plaintext));
9545 assert!(rendered_public.contains(header_plaintext));
9546 let compatible: bamboo_domain::mcp_config::McpConfig =
9547 serde_json::from_value(public).unwrap();
9548 assert_eq!(compatible.servers.len(), 2);
9549
9550 config.features.provider_model_ref = !config.features.provider_model_ref;
9551 config.save_to_dir(temp_home.path.clone()).unwrap();
9552 config.features.provider_model_ref = !config.features.provider_model_ref;
9553 config.save_to_dir(temp_home.path.clone()).unwrap();
9554
9555 for entry in std::fs::read_dir(&temp_home.path)
9556 .unwrap()
9557 .filter_map(Result::ok)
9558 {
9559 let name = entry.file_name().to_string_lossy().to_string();
9560 if name == "credentials.json" || entry.path().is_dir() {
9561 continue;
9562 }
9563 if name == "config.json"
9564 || name == "mcp.json"
9565 || name.contains(".bak")
9566 || name.starts_with("config-credential-migration")
9567 {
9568 let bytes = std::fs::read(entry.path()).unwrap();
9569 let content = String::from_utf8_lossy(&bytes);
9570 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9571 assert!(
9572 !content.contains(header_plaintext),
9573 "secret leaked to {name}"
9574 );
9575 assert!(
9576 !content.contains(&legacy_ciphertext),
9577 "legacy ciphertext leaked to {name}"
9578 );
9579 }
9580 }
9581 }
9582
9583 #[test]
9586 fn env_vars_as_map_includes_only_non_empty_values() {
9587 let mut config = Config::default();
9588 config.env_vars.extend([
9589 EnvVarEntry {
9590 name: "A".to_string(),
9591 value: "val_a".to_string(),
9592 secret: false,
9593 value_encrypted: None,
9594 credential_ref: None,
9595 configured: true,
9596 description: None,
9597 },
9598 EnvVarEntry {
9599 name: "B".to_string(),
9600 value: "".to_string(), secret: true,
9602 value_encrypted: None,
9603 credential_ref: None,
9604 configured: false,
9605 description: None,
9606 },
9607 EnvVarEntry {
9608 name: "C".to_string(),
9609 value: " ".to_string(), secret: false,
9611 value_encrypted: None,
9612 credential_ref: None,
9613 configured: true,
9614 description: None,
9615 },
9616 EnvVarEntry {
9617 name: "D".to_string(),
9618 value: "val_d".to_string(),
9619 secret: true,
9620 value_encrypted: Some("enc".to_string()),
9621 credential_ref: None,
9622 configured: true,
9623 description: Some("desc".to_string()),
9624 },
9625 ]);
9626
9627 let map = config.env_vars_as_map();
9628 assert_eq!(map.len(), 2);
9629 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9630 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9631 assert!(!map.contains_key("B"));
9632 assert!(!map.contains_key("C"));
9633 }
9634
9635 #[test]
9636 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9637 let mut config = Config::default();
9638 config.env_vars.extend([
9639 EnvVarEntry {
9640 name: "PLAIN".to_string(),
9641 value: "visible".to_string(),
9642 secret: false,
9643 value_encrypted: None,
9644 credential_ref: None,
9645 configured: true,
9646 description: None,
9647 },
9648 EnvVarEntry {
9649 name: "SECRET".to_string(),
9650 value: "hidden_value".to_string(),
9651 secret: true,
9652 value_encrypted: Some("enc_data".to_string()),
9653 credential_ref: None,
9654 configured: true,
9655 description: None,
9656 },
9657 ]);
9658
9659 config.sanitize_env_vars_for_disk();
9660
9661 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
9664
9665 #[test]
9666 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9667 let mut config = Config::default();
9668 config.env_vars.extend([
9669 EnvVarEntry {
9670 name: "OPEN".to_string(),
9671 value: "val".to_string(),
9672 secret: false,
9673 value_encrypted: None,
9674 credential_ref: None,
9675 configured: true,
9676 description: None,
9677 },
9678 EnvVarEntry {
9679 name: "HIDDEN".to_string(),
9680 value: "real_secret".to_string(),
9681 secret: true,
9682 value_encrypted: Some("enc".to_string()),
9683 credential_ref: None,
9684 configured: true,
9685 description: None,
9686 },
9687 ]);
9688
9689 config.sanitize_env_vars_for_disk();
9690
9691 assert_eq!(config.env_vars[0].value, "val");
9693 assert_eq!(config.env_vars[1].value, "");
9695 assert!(config.env_vars[1].value_encrypted.is_none());
9696 }
9697
9698 #[test]
9699 fn legacy_env_ciphertext_is_read_but_never_serialized() {
9700 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9701 let mut config: Config = serde_json::from_value(serde_json::json!({
9702 "env_vars": [{
9703 "name": "TOKEN",
9704 "secret": true,
9705 "value_encrypted": ciphertext
9706 }]
9707 }))
9708 .unwrap();
9709 config.hydrate_env_vars_from_encrypted();
9710 assert_eq!(config.env_vars[0].value, "my-secret-token");
9711 let serialized = serde_json::to_value(&config).unwrap();
9712 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9713 }
9714
9715 #[test]
9716 fn configured_env_ref_missing_from_store_fails_closed() {
9717 let dir = tempfile::tempdir().unwrap();
9718 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9719 let mut config = Config::default();
9720 config.env_vars.push(EnvVarEntry {
9721 name: "TOKEN".to_string(),
9722 value: String::new(),
9723 secret: true,
9724 value_encrypted: None,
9725 credential_ref: Some(reference),
9726 configured: true,
9727 description: None,
9728 });
9729 let error = config
9730 .hydrate_env_var_credentials_from_store(dir.path())
9731 .unwrap_err();
9732 assert!(error
9733 .to_string()
9734 .contains("referenced env credential is unavailable"));
9735 assert!(config.env_vars[0].configured);
9736 assert!(config.env_vars[0].value.is_empty());
9737 }
9738
9739 #[test]
9740 fn configured_notification_ref_missing_from_store_fails_closed() {
9741 let dir = tempfile::tempdir().unwrap();
9742 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9743 let mut config = Config::default();
9744 config.notifications.ntfy.credential_ref = Some(reference);
9745 config.notifications.ntfy.configured = true;
9746
9747 let error = config
9748 .hydrate_notification_credentials_from_store(dir.path())
9749 .unwrap_err();
9750
9751 assert!(error
9752 .to_string()
9753 .contains("referenced ntfy credential is unavailable"));
9754 assert!(config.notifications.ntfy.configured);
9755 assert!(config.notifications.ntfy.token.is_none());
9756 }
9757
9758 #[test]
9759 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9760 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9761 let dir = tempfile::tempdir().unwrap();
9762 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9763 crate::CredentialStore::open(dir.path())
9764 .replace(
9765 reference.clone(),
9766 "exclusive-notification-secret",
9767 crate::CredentialSource::User,
9768 0,
9769 )
9770 .unwrap();
9771
9772 let mut config = Config::default();
9773 config.notifications.ntfy.credential_ref = Some(reference.clone());
9774 config.notifications.ntfy.configured = true;
9775 config.proxy_auth_credential_ref = Some(reference.clone());
9776 let error = config
9777 .hydrate_notification_credentials_from_store(dir.path())
9778 .unwrap_err();
9779 let rendered = error.to_string();
9780 assert!(rendered
9781 .contains("notification credential reference is shared by another config consumer"));
9782 assert!(!rendered.contains(reference.as_str()));
9783 assert!(config.notifications.ntfy.token.is_none());
9784
9785 config.proxy_auth_credential_ref = None;
9786 config
9787 .hydrate_notification_credentials_from_store(dir.path())
9788 .unwrap();
9789 assert_eq!(
9790 config.notifications.ntfy.token.as_deref(),
9791 Some("exclusive-notification-secret")
9792 );
9793 }
9794
9795 #[test]
9796 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9797 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9798 let dir = tempfile::tempdir().unwrap();
9799 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9800 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9801 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9802 "notifications": {
9803 "ntfy": { "enabled": true, "token_encrypted": ntfy },
9804 "bark": { "enabled": true, "device_key_encrypted": bark }
9805 }
9806 }))
9807 .unwrap();
9808 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9809 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9810
9811 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9812
9813 assert!(loaded.notifications.ntfy.token.is_none());
9814 assert!(loaded.notifications.bark.device_key.is_none());
9815 assert_eq!(
9816 std::fs::read(dir.path().join("config.json")).unwrap(),
9817 bytes
9818 );
9819 }
9820
9821 #[test]
9822 fn publish_and_current_env_vars_round_trip() {
9823 let _lock = crate::test_support::env_cache_lock_acquire();
9833 let mut config = Config::default();
9834 config.env_vars.extend([EnvVarEntry {
9835 name: "TEST_PUBLISH".to_string(),
9836 value: "pub_value".to_string(),
9837 secret: false,
9838 value_encrypted: None,
9839 credential_ref: None,
9840 configured: true,
9841 description: None,
9842 }]);
9843
9844 config.publish_env_vars();
9845 assert_eq!(
9846 Config::current_env_vars()
9847 .get("TEST_PUBLISH")
9848 .map(String::as_str),
9849 Some("pub_value")
9850 );
9851 }
9852
9853 #[test]
9854 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9855 let mut config = Config::default();
9856 config.subagents.broker = Some(BrokerClientConfig {
9857 endpoint: "ws://127.0.0.1:9600".to_string(),
9858 token: "super-secret-token".to_string(),
9859 token_encrypted: None,
9860 credential_ref: None,
9861 configured: false,
9862 });
9863
9864 config.refresh_broker_token_encrypted().unwrap();
9866 config.sanitize_broker_token_for_disk();
9867 let broker = config.subagents.broker.as_ref().unwrap();
9868 assert!(broker.token.is_empty(), "plaintext cleared for disk");
9869 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9870 assert_ne!(
9871 broker.token_encrypted.as_deref(),
9872 Some("super-secret-token")
9873 );
9874
9875 config.hydrate_broker_token_from_encrypted();
9877 assert_eq!(
9878 config.subagents.broker.as_ref().unwrap().token,
9879 "super-secret-token"
9880 );
9881 }
9882
9883 #[test]
9884 fn broker_token_empty_refresh_preserves_ciphertext() {
9885 let mut config = Config::default();
9887 config.subagents.broker = Some(BrokerClientConfig {
9888 endpoint: "ws://h:9600".to_string(),
9889 token: String::new(),
9890 token_encrypted: Some("existing-cipher".to_string()),
9891 credential_ref: None,
9892 configured: false,
9893 });
9894 config.refresh_broker_token_encrypted().unwrap();
9895 assert_eq!(
9896 config
9897 .subagents
9898 .broker
9899 .as_ref()
9900 .unwrap()
9901 .token_encrypted
9902 .as_deref(),
9903 Some("existing-cipher"),
9904 );
9905 }
9906
9907 #[test]
9908 fn notifications_config_defaults_when_key_missing() {
9909 let config: Config = serde_json::from_str("{}").expect("empty object parses");
9912 assert_eq!(config.notifications, NotificationsConfig::default());
9913 assert_eq!(config.notifications.desktop.enabled, None);
9914 assert!(!config.notifications.ntfy.enabled);
9915 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
9916 assert_eq!(config.notifications.ntfy.token, None);
9917 assert!(!config.notifications.bark.enabled);
9918 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
9919 assert_eq!(config.notifications.bark.device_key, None);
9920 }
9921
9922 #[test]
9923 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
9924 let mut config = Config::default();
9925 config.notifications.ntfy = NtfyChannelConfig {
9926 enabled: true,
9927 base_url: "https://ntfy.sh".to_string(),
9928 topic: "bamboo-alerts".to_string(),
9929 token: Some("tk_super_secret".to_string()),
9930 token_encrypted: None,
9931 credential_ref: None,
9932 configured: false,
9933 };
9934
9935 config.refresh_notifications_encrypted().unwrap();
9938 assert!(config.notifications.ntfy.token_encrypted.is_some());
9939 assert_ne!(
9940 config.notifications.ntfy.token_encrypted.as_deref(),
9941 Some("tk_super_secret")
9942 );
9943
9944 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
9946 assert!(
9947 !json.contains("tk_super_secret"),
9948 "plaintext token must never be serialized"
9949 );
9950 assert!(!json.contains("token_encrypted"));
9951
9952 config.notifications.ntfy.token = None;
9954 config.hydrate_notifications_from_encrypted();
9955 assert_eq!(
9956 config.notifications.ntfy.token.as_deref(),
9957 Some("tk_super_secret")
9958 );
9959 }
9960
9961 #[test]
9962 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
9963 let mut config = Config::default();
9964 config.notifications.bark = BarkChannelConfig {
9965 enabled: true,
9966 base_url: "https://api.day.app".to_string(),
9967 device_key: Some("dk_super_secret".to_string()),
9968 device_key_encrypted: None,
9969 credential_ref: None,
9970 configured: false,
9971 };
9972
9973 config.refresh_notifications_encrypted().unwrap();
9974 assert!(config.notifications.bark.device_key_encrypted.is_some());
9975 assert_ne!(
9976 config.notifications.bark.device_key_encrypted.as_deref(),
9977 Some("dk_super_secret")
9978 );
9979
9980 let json = serde_json::to_string(&config.notifications.bark).unwrap();
9981 assert!(
9982 !json.contains("dk_super_secret"),
9983 "plaintext device key must never be serialized"
9984 );
9985 assert!(!json.contains("device_key_encrypted"));
9986
9987 config.notifications.bark.device_key = None;
9988 config.hydrate_notifications_from_encrypted();
9989 assert_eq!(
9990 config.notifications.bark.device_key.as_deref(),
9991 Some("dk_super_secret")
9992 );
9993 }
9994
9995 #[test]
9996 fn notification_secrets_empty_refresh_preserves_ciphertext() {
9997 let mut config = Config::default();
10000 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10001 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10002
10003 config.refresh_notifications_encrypted().unwrap();
10004
10005 assert_eq!(
10006 config.notifications.ntfy.token_encrypted.as_deref(),
10007 Some("existing-ntfy-cipher")
10008 );
10009 assert_eq!(
10010 config.notifications.bark.device_key_encrypted.as_deref(),
10011 Some("existing-bark-cipher")
10012 );
10013 }
10014
10015 #[test]
10016 fn hydrate_skips_non_secret_entries() {
10017 let mut config = Config::default();
10018 config.env_vars.extend([EnvVarEntry {
10019 name: "PLAIN".to_string(),
10020 value: "original".to_string(),
10021 secret: false,
10022 value_encrypted: Some("should-be-ignored".to_string()),
10023 credential_ref: None,
10024 configured: true,
10025 description: None,
10026 }]);
10027
10028 config.hydrate_env_vars_from_encrypted();
10029 assert_eq!(config.env_vars[0].value, "original");
10031 }
10032
10033 #[test]
10034 fn default_config_has_empty_env_vars() {
10035 assert!(Config::default().env_vars.is_empty());
10040 }
10041
10042 #[test]
10043 fn serde_round_trip_with_env_vars() {
10044 let mut config = Config::default();
10045 config.env_vars.extend([
10046 EnvVarEntry {
10047 name: "KEY1".to_string(),
10048 value: "val1".to_string(),
10049 secret: false,
10050 value_encrypted: None,
10051 credential_ref: None,
10052 configured: true,
10053 description: Some("First key".to_string()),
10054 },
10055 EnvVarEntry {
10056 name: "KEY2".to_string(),
10057 value: "".to_string(), secret: true,
10059 value_encrypted: Some("enc123".to_string()),
10060 credential_ref: None,
10061 configured: true,
10062 description: None,
10063 },
10064 ]);
10065
10066 let json = serde_json::to_string(&config).unwrap();
10067 let restored: Config = serde_json::from_str(&json).unwrap();
10068
10069 assert_eq!(restored.env_vars.len(), 2);
10070 assert_eq!(restored.env_vars[0].name, "KEY1");
10071 assert_eq!(restored.env_vars[0].value, "val1");
10072 assert!(!restored.env_vars[0].secret);
10073 assert_eq!(restored.env_vars[1].name, "KEY2");
10074 assert!(restored.env_vars[1].secret);
10075 assert!(restored.env_vars[1].value_encrypted.is_none());
10076 }
10077
10078 #[test]
10081 #[allow(clippy::field_reassign_with_default)]
10083 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10084 let mut config = Config::default();
10085 config.provider = "openai".to_string();
10086 config.providers.openai = Some(OpenAIConfig {
10087 api_key: "test".to_string(),
10088 api_key_encrypted: None,
10089 credential_ref: None,
10090 base_url: None,
10091 model: Some("legacy-gpt-4o".to_string()),
10092 fast_model: None,
10093 vision_model: None,
10094 reasoning_effort: None,
10095 responses_only_models: vec![],
10096 request_overrides: None,
10097 extra: Default::default(),
10098 api_key_from_env: false,
10099 });
10100 config.features.provider_model_ref = true;
10101 config.defaults = Some(DefaultsConfig {
10102 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10103 fast: None,
10104 task_summary: None,
10105 vision: None,
10106 memory_background: None,
10107 planning: None,
10108 search: None,
10109 code_review: None,
10110 sub_agent: None,
10111 subagent_models: Default::default(),
10112 });
10113
10114 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10115 }
10116
10117 #[test]
10118 #[allow(clippy::field_reassign_with_default)]
10120 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10121 let mut config = Config::default();
10122 config.provider = "openai".to_string();
10123 config.providers.openai = Some(OpenAIConfig {
10124 api_key: "test".to_string(),
10125 api_key_encrypted: None,
10126 credential_ref: None,
10127 base_url: None,
10128 model: Some("legacy-gpt-4o".to_string()),
10129 fast_model: None,
10130 vision_model: None,
10131 reasoning_effort: None,
10132 responses_only_models: vec![],
10133 request_overrides: None,
10134 extra: Default::default(),
10135 api_key_from_env: false,
10136 });
10137 config.features.provider_model_ref = false;
10138 config.defaults = Some(DefaultsConfig {
10139 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10140 fast: None,
10141 task_summary: None,
10142 vision: None,
10143 memory_background: None,
10144 planning: None,
10145 search: None,
10146 code_review: None,
10147 sub_agent: None,
10148 subagent_models: Default::default(),
10149 });
10150
10151 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10152 }
10153
10154 #[test]
10155 #[allow(clippy::field_reassign_with_default)]
10157 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10158 let mut config = Config::default();
10159 config.provider = "openai".to_string();
10160 config.providers.openai = Some(OpenAIConfig {
10161 api_key: "test".to_string(),
10162 api_key_encrypted: None,
10163 credential_ref: None,
10164 base_url: None,
10165 model: Some("gpt-4o".to_string()),
10166 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10167 vision_model: None,
10168 reasoning_effort: None,
10169 responses_only_models: vec![],
10170 request_overrides: None,
10171 extra: Default::default(),
10172 api_key_from_env: false,
10173 });
10174 config.features.provider_model_ref = true;
10175 config.defaults = Some(DefaultsConfig {
10176 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10177 fast: Some(bamboo_domain::ProviderModelRef::new(
10178 "anthropic",
10179 "claude-3-5-haiku",
10180 )),
10181 task_summary: None,
10182 vision: None,
10183 memory_background: None,
10184 planning: None,
10185 search: None,
10186 code_review: None,
10187 sub_agent: None,
10188 subagent_models: Default::default(),
10189 });
10190
10191 assert_eq!(
10192 config.get_fast_model(),
10193 Some("claude-3-5-haiku".to_string())
10194 );
10195 }
10196
10197 #[test]
10198 #[allow(clippy::field_reassign_with_default)]
10200 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10201 let mut config = Config::default();
10202 config.provider = "openai".to_string();
10203 config.providers.openai = Some(OpenAIConfig {
10204 api_key: "test".to_string(),
10205 api_key_encrypted: None,
10206 credential_ref: None,
10207 base_url: None,
10208 model: Some("gpt-4o".to_string()),
10209 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10210 vision_model: None,
10211 reasoning_effort: None,
10212 responses_only_models: vec![],
10213 request_overrides: None,
10214 extra: Default::default(),
10215 api_key_from_env: false,
10216 });
10217 config.features.provider_model_ref = false;
10218 config.defaults = Some(DefaultsConfig {
10219 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10220 fast: Some(bamboo_domain::ProviderModelRef::new(
10221 "anthropic",
10222 "claude-3-5-haiku",
10223 )),
10224 task_summary: None,
10225 vision: None,
10226 memory_background: None,
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_fast_model(),
10236 Some("legacy-gpt-4o-mini".to_string())
10237 );
10238 }
10239
10240 #[test]
10241 #[allow(clippy::field_reassign_with_default)]
10243 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10244 let mut config = Config::default();
10245 config.provider = "openai".to_string();
10246 config.features.provider_model_ref = true;
10247 config.defaults = Some(DefaultsConfig {
10248 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10249 fast: None,
10250 task_summary: None,
10251 vision: None,
10252 memory_background: None,
10253 planning: None,
10254 search: None,
10255 code_review: None,
10256 sub_agent: None,
10257 subagent_models: Default::default(),
10258 });
10259
10260 assert_eq!(
10261 config.get_fast_model(),
10262 Some("claude-3-7-sonnet".to_string())
10263 );
10264 }
10265
10266 #[test]
10267 #[allow(clippy::field_reassign_with_default)]
10269 fn get_memory_background_model_prefers_defaults_memory_background() {
10270 let mut config = Config::default();
10271 config.provider = "openai".to_string();
10272 config.providers.openai = Some(OpenAIConfig {
10273 api_key: "test".to_string(),
10274 api_key_encrypted: None,
10275 credential_ref: None,
10276 base_url: None,
10277 model: Some("gpt-4o".to_string()),
10278 fast_model: Some("gpt-4o-mini".to_string()),
10279 vision_model: None,
10280 reasoning_effort: None,
10281 responses_only_models: vec![],
10282 request_overrides: None,
10283 extra: Default::default(),
10284 api_key_from_env: false,
10285 });
10286 config.features.provider_model_ref = true;
10287 config.defaults = Some(DefaultsConfig {
10288 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10289 fast: Some(bamboo_domain::ProviderModelRef::new(
10290 "openai",
10291 "gpt-4o-mini",
10292 )),
10293 task_summary: None,
10294 vision: None,
10295 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10296 "anthropic",
10297 "claude-3-5-haiku",
10298 )),
10299 planning: None,
10300 search: None,
10301 code_review: None,
10302 sub_agent: None,
10303 subagent_models: Default::default(),
10304 });
10305
10306 assert_eq!(
10307 config.get_memory_background_model(),
10308 Some("claude-3-5-haiku".to_string())
10309 );
10310 }
10311
10312 #[test]
10313 #[allow(clippy::field_reassign_with_default)]
10315 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10316 let mut config = Config::default();
10317 config.provider = "openai".to_string();
10318 config.features.provider_model_ref = true;
10319 config.defaults = Some(DefaultsConfig {
10320 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10321 fast: Some(bamboo_domain::ProviderModelRef::new(
10322 "anthropic",
10323 "claude-3-5-haiku",
10324 )),
10325 task_summary: None,
10326 vision: None,
10327 memory_background: None,
10328 planning: None,
10329 search: None,
10330 code_review: None,
10331 sub_agent: None,
10332 subagent_models: Default::default(),
10333 });
10334
10335 assert_eq!(
10336 config.get_memory_background_model(),
10337 Some("claude-3-5-haiku".to_string())
10338 );
10339 }
10340
10341 #[test]
10342 #[allow(clippy::field_reassign_with_default)]
10344 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10345 let mut config = Config::default();
10346 config.provider = "openai".to_string();
10347 config.providers.openai = Some(OpenAIConfig {
10348 api_key: "test".to_string(),
10349 api_key_encrypted: None,
10350 credential_ref: None,
10351 base_url: None,
10352 model: Some("gpt-4o".to_string()),
10353 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10354 vision_model: None,
10355 reasoning_effort: None,
10356 responses_only_models: vec![],
10357 request_overrides: None,
10358 extra: Default::default(),
10359 api_key_from_env: false,
10360 });
10361 config.features.provider_model_ref = false;
10362 config.defaults = Some(DefaultsConfig {
10363 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10364 fast: Some(bamboo_domain::ProviderModelRef::new(
10365 "anthropic",
10366 "claude-3-5-haiku",
10367 )),
10368 task_summary: None,
10369 vision: None,
10370 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10371 "anthropic",
10372 "claude-3-5-haiku",
10373 )),
10374 planning: None,
10375 search: None,
10376 code_review: None,
10377 sub_agent: None,
10378 subagent_models: Default::default(),
10379 });
10380
10381 assert_eq!(
10382 config.get_memory_background_model(),
10383 Some("legacy-gpt-4o-mini".to_string())
10384 );
10385 }
10386
10387 #[test]
10394 fn is_host_trusted_requires_https_scheme() {
10395 let hosts = vec!["github.com/bigduu/".to_string()];
10396 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10397 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10398 }
10399
10400 #[test]
10401 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10402 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10404 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10405 let hosts = vec!["github.com/bigduu/".to_string()];
10407 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10408 }
10409
10410 #[test]
10411 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10412 let hosts = vec!["trusted.example.com".to_string()];
10413 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10414 assert!(!is_host_trusted(
10418 "https://trusted.example.com.evil.com/x",
10419 &hosts
10420 ));
10421 assert!(!is_host_trusted(
10422 "https://trusted.example.comevil.com/x",
10423 &hosts
10424 ));
10425 }
10426
10427 #[test]
10428 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10429 let hosts = vec!["github.com/bigduu/".to_string()];
10431 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10432 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10433 }
10434
10435 #[test]
10436 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10437 let hosts = vec!["example.com".to_string()];
10438 assert!(is_host_trusted("https://example.com/", &hosts));
10439 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10440 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10443 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10444 }
10445
10446 #[test]
10447 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10448 let hosts = vec!["github.com/bigduu/".to_string()];
10449 assert!(is_host_trusted(
10451 "https://someuser@github.com/bigduu/x",
10452 &hosts
10453 ));
10454 assert!(!is_host_trusted(
10457 "https://github.com@evil.com/bigduu/",
10458 &hosts
10459 ));
10460 }
10461
10462 #[test]
10463 fn is_host_trusted_ignores_an_explicit_port() {
10464 let hosts = vec!["github.com/bigduu/".to_string()];
10465 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10466 }
10467
10468 #[test]
10469 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10470 let hosts = vec!["github.com/bigduu/".to_string()];
10471 assert!(!is_host_trusted("not a url at all", &hosts));
10472 assert!(!is_host_trusted("", &hosts));
10473 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10475
10476 #[test]
10477 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10478 let hosts = vec!["github.com/bigduu/".to_string()];
10479 assert!(!is_host_trusted(
10482 "https://github.com/bigduu/../evil/x",
10483 &hosts
10484 ));
10485 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10487 }
10488
10489 #[test]
10490 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10491 let mut config = Config::default();
10492 config.plugin_trust.trusted_hosts = vec![
10493 " GitHub.com/BigDuu/ ".to_string(),
10494 "".to_string(),
10495 " ".to_string(),
10496 "Example.COM".to_string(),
10497 ];
10498 config.normalize_plugin_trust_settings();
10499 assert_eq!(
10500 config.plugin_trust.trusted_hosts,
10501 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10502 );
10503 }
10504
10505 #[test]
10511 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10512 let config = Config::default();
10514 assert_eq!(
10515 config.plugin_trust.enforcement,
10516 PluginTrustEnforcement::Strict
10517 );
10518 assert!(!config.plugin_trust.enforcement_is_off());
10519
10520 let json = serde_json::json!({
10526 "trusted_hosts": ["example.com"],
10527 "trusted_keys": [],
10528 });
10529 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10530 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10531 }
10532
10533 #[test]
10534 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10535 for raw in ["off", "OFF", "Off", " off "] {
10536 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10537 "enforcement": raw,
10538 }))
10539 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10540 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10541 assert!(trust.enforcement_is_off());
10542 }
10543 for raw in ["strict", "STRICT", " Strict "] {
10544 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10545 "enforcement": raw,
10546 }))
10547 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10548 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10549 }
10550
10551 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10552 "enforcement": "nonsense",
10553 }))
10554 .expect_err("an unrecognized string must be rejected, not silently default");
10555 assert!(err.to_string().contains("nonsense"));
10556 }
10557
10558 #[test]
10559 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10560 let trust: PluginTrustConfig =
10563 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10564 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10565
10566 let trust: PluginTrustConfig =
10567 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10568 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10569 }
10570
10571 #[test]
10572 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10573 let trust = PluginTrustConfig {
10578 enforcement: PluginTrustEnforcement::Off,
10579 ..PluginTrustConfig::default()
10580 };
10581 let json = serde_json::to_value(&trust).unwrap();
10582 assert_eq!(json["enforcement"], "off");
10583
10584 let trust = PluginTrustConfig {
10585 enforcement: PluginTrustEnforcement::Strict,
10586 ..PluginTrustConfig::default()
10587 };
10588 let json = serde_json::to_value(&trust).unwrap();
10589 assert_eq!(json["enforcement"], "strict");
10590 }
10591
10592 #[test]
10593 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10594 let mut config = Config::default();
10597 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10598 config.normalize_plugin_trust_settings();
10599 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10600 }
10601
10602 #[test]
10603 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10604 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10609 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10610 )));
10611 assert_eq!(
10612 config.plugin_trust.enforcement,
10613 PluginTrustEnforcement::Strict
10614 );
10615
10616 let outcome = crate::dot_path::apply_dot_path_set(
10617 &config,
10618 "plugin_trust.enforcement",
10619 crate::dot_path::parse_cli_value("off"),
10620 )
10621 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10622 assert_eq!(
10623 outcome.config.plugin_trust.enforcement,
10624 PluginTrustEnforcement::Off
10625 );
10626
10627 let outcome = crate::dot_path::apply_dot_path_set(
10629 &outcome.config,
10630 "plugin_trust.enforcement",
10631 crate::dot_path::parse_cli_value("strict"),
10632 )
10633 .expect("setting it back to strict should also round-trip");
10634 assert_eq!(
10635 outcome.config.plugin_trust.enforcement,
10636 PluginTrustEnforcement::Strict
10637 );
10638 }
10639}