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
698pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
700
701#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
712pub struct SubagentsConfig {
713 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub max_spawn_depth: Option<u32>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub max_concurrent: Option<usize>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub worker_bin: Option<String>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub worker_args: Option<Vec<String>>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub fabric_dir: Option<String>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub executor: Option<String>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub claude_code_binary: Option<String>,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub claude_code_model: Option<String>,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub claude_code_permission_mode: Option<String>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub claude_code_inherit_user_config: Option<bool>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub claude_code_forward_env: Option<Vec<String>>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub codex_binary: Option<String>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub codex_model: Option<String>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub codex_mode: Option<CodexMode>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub codex_auth_mode: Option<CodexAuthMode>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub codex_base_url: Option<String>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub codex_wire_api: Option<CodexWireApi>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub codex_provider_key_ref: Option<crate::CredentialRef>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub codex_forward_env: Option<Vec<String>>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub codex_sandbox: Option<CodexSandbox>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub codex_approval_policy: Option<CodexApprovalPolicy>,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub codex_network_access: Option<bool>,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub codex_allow_danger_bypass: Option<bool>,
809 #[serde(skip)]
816 pub broker: Option<BrokerClientConfig>,
817 #[serde(default, skip_serializing_if = "Vec::is_empty")]
823 pub remote_placements: Vec<RemoteActorPlacement>,
824 #[serde(default, skip_serializing_if = "Vec::is_empty")]
837 pub schedulable_placements: Vec<SchedulablePlacement>,
838 #[serde(default, skip_serializing_if = "Vec::is_empty")]
857 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
858}
859
860impl SubagentsConfig {
861 pub fn effective_max_spawn_depth(&self) -> u32 {
862 self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
863 }
864}
865
866#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
869pub struct McpRoleAllowlistEntry {
870 pub role: String,
878 #[serde(default)]
886 pub tools: Vec<String>,
887}
888
889#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
895#[serde(deny_unknown_fields)]
896pub struct OperatorPlacementRequirements {
897 pub trust_zone: String,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub workspace_label: Option<String>,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub network_zone: Option<String>,
902 #[serde(default)]
903 pub require_network_isolation: bool,
904 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
907 pub required_tools: std::collections::BTreeSet<String>,
908}
909
910#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
913#[serde(deny_unknown_fields)]
914pub struct SchedulableBrokerParent {
915 pub parent_mailbox: String,
916 pub parent_role: String,
917}
918
919#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
923pub struct SchedulablePlacement {
924 pub role: String,
927 pub pool: String,
929 #[serde(default, skip_serializing_if = "String::is_empty")]
931 pub registry_url: String,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub token_env: Option<String>,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub ca_cert_file: Option<String>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub broker_parent: Option<SchedulableBrokerParent>,
945 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub placement_requirements: Option<OperatorPlacementRequirements>,
947}
948
949#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
960pub struct RemoteActorPlacement {
961 pub role: String,
964 pub endpoint: String,
967 #[serde(default, skip_serializing_if = "Option::is_none")]
970 pub token_env: Option<String>,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub ca_cert_file: Option<String>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub broker_peer: Option<RemoteBrokerPeer>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub placement_requirements: Option<OperatorPlacementRequirements>,
981}
982
983#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
985#[serde(deny_unknown_fields)]
986pub struct RemoteBrokerPeer {
987 pub parent_mailbox: String,
988 pub worker_mailbox: String,
989 pub parent_role: Option<String>,
990 pub worker_role: Option<String>,
991}
992impl RemoteBrokerPeer {
993 pub fn valid(&self) -> bool {
994 fn identifier(s: &str, mailbox: bool) -> bool {
995 !s.is_empty()
996 && s.len() <= 256
997 && s != "."
998 && s != ".."
999 && s.bytes()
1000 .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1001 && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1002 }
1003 identifier(&self.parent_mailbox, true)
1004 && identifier(&self.worker_mailbox, true)
1005 && self.parent_mailbox != self.worker_mailbox
1006 && self
1007 .parent_role
1008 .as_deref()
1009 .is_none_or(|s| identifier(s, false))
1010 && self
1011 .worker_role
1012 .as_deref()
1013 .is_some_and(|s| identifier(s, false))
1014 }
1015}
1016
1017#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1019pub struct BrokerClientConfig {
1020 pub endpoint: String,
1022 #[serde(default, skip_serializing)]
1028 pub token: String,
1029 #[serde(default, skip_serializing)]
1031 pub token_encrypted: Option<String>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub credential_ref: Option<crate::CredentialRef>,
1035 #[serde(default)]
1038 pub configured: bool,
1039}
1040
1041impl std::fmt::Debug for BrokerClientConfig {
1042 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1043 formatter
1044 .debug_struct("BrokerClientConfig")
1045 .field("endpoint", &self.endpoint)
1046 .field("credential_ref", &self.credential_ref)
1047 .field("configured", &self.configured)
1048 .finish_non_exhaustive()
1049 }
1050}
1051
1052#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1054pub struct DesktopChannelConfig {
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub enabled: Option<bool>,
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1067pub struct NtfyChannelConfig {
1068 #[serde(default)]
1069 pub enabled: bool,
1070 #[serde(default = "default_ntfy_base_url")]
1072 pub base_url: String,
1073 #[serde(default)]
1076 pub topic: String,
1077 #[serde(default, skip_serializing)]
1083 pub token: Option<String>,
1084 #[serde(default, skip_serializing)]
1086 pub token_encrypted: Option<String>,
1087 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub credential_ref: Option<crate::CredentialRef>,
1090 #[serde(default)]
1092 pub configured: bool,
1093}
1094
1095impl Default for NtfyChannelConfig {
1096 fn default() -> Self {
1097 Self {
1098 enabled: false,
1099 base_url: default_ntfy_base_url(),
1100 topic: String::new(),
1101 token: None,
1102 token_encrypted: None,
1103 credential_ref: None,
1104 configured: false,
1105 }
1106 }
1107}
1108
1109fn default_ntfy_base_url() -> String {
1110 "https://ntfy.sh".to_string()
1111}
1112
1113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1115pub struct BarkChannelConfig {
1116 #[serde(default)]
1117 pub enabled: bool,
1118 #[serde(default = "default_bark_base_url")]
1120 pub base_url: String,
1121 #[serde(default, skip_serializing)]
1126 pub device_key: Option<String>,
1127 #[serde(default, skip_serializing)]
1129 pub device_key_encrypted: Option<String>,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub credential_ref: Option<crate::CredentialRef>,
1133 #[serde(default)]
1135 pub configured: bool,
1136}
1137
1138impl Default for BarkChannelConfig {
1139 fn default() -> Self {
1140 Self {
1141 enabled: false,
1142 base_url: default_bark_base_url(),
1143 device_key: None,
1144 device_key_encrypted: None,
1145 credential_ref: None,
1146 configured: false,
1147 }
1148 }
1149}
1150
1151fn default_bark_base_url() -> String {
1152 "https://api.day.app".to_string()
1153}
1154
1155#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1160pub struct NotificationsConfig {
1161 #[serde(default)]
1162 pub desktop: DesktopChannelConfig,
1163 #[serde(default)]
1164 pub ntfy: NtfyChannelConfig,
1165 #[serde(default)]
1166 pub bark: BarkChannelConfig,
1167}
1168
1169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1173pub struct ConnectPlatformConfig {
1174 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub id: Option<String>,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub project_id: Option<bamboo_domain::ProjectId>,
1194 #[serde(rename = "type")]
1198 pub platform_type: String,
1199 #[serde(default, skip_serializing)]
1205 pub token: Option<String>,
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub token_encrypted: Option<String>,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1211 pub token_credential_ref: Option<crate::CredentialRef>,
1212 #[serde(default)]
1214 pub token_configured: bool,
1215 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub app_id: Option<String>,
1219 #[serde(default, skip_serializing)]
1225 pub app_secret: Option<String>,
1226 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub app_secret_encrypted: Option<String>,
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub app_secret_credential_ref: Option<crate::CredentialRef>,
1232 #[serde(default)]
1234 pub app_secret_configured: bool,
1235 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub domain: Option<String>,
1242 #[serde(default)]
1247 pub allow_from: Vec<String>,
1248 #[serde(default)]
1252 pub admin_from: Vec<String>,
1253}
1254
1255#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1259pub struct ConnectConfig {
1260 #[serde(default)]
1261 pub platforms: Vec<ConnectPlatformConfig>,
1262}
1263
1264fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1265 config.platforms.is_empty()
1266}
1267
1268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1275pub struct TrustedKey {
1276 pub label: String,
1278 pub algorithm: String,
1280 pub public_key: String,
1282}
1283
1284pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1286 "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1287
1288fn default_trusted_keys() -> Vec<TrustedKey> {
1295 vec![
1296 TrustedKey {
1297 label: "jiandu (bigduu official)".to_string(),
1298 algorithm: "ed25519".to_string(),
1299 public_key: JIANDU_PLUGIN_PUBLIC_KEY.to_string(),
1300 },
1301 TrustedKey {
1302 label: "nova (bigduu official)".to_string(),
1303 algorithm: "ed25519".to_string(),
1304 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1305 .to_string(),
1306 },
1307 TrustedKey {
1308 label: "magpie (bigduu official)".to_string(),
1309 algorithm: "ed25519".to_string(),
1310 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1311 .to_string(),
1312 },
1313 ]
1314}
1315
1316fn default_trusted_hosts() -> Vec<String> {
1319 vec!["github.com/bigduu/".to_string()]
1320}
1321
1322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1339pub struct PluginTrustConfig {
1340 #[serde(default = "default_trusted_hosts")]
1351 pub trusted_hosts: Vec<String>,
1352 #[serde(default = "default_trusted_keys")]
1355 pub trusted_keys: Vec<TrustedKey>,
1356 #[serde(default)]
1370 pub enforcement: PluginTrustEnforcement,
1371}
1372
1373impl Default for PluginTrustConfig {
1374 fn default() -> Self {
1375 Self {
1376 trusted_hosts: default_trusted_hosts(),
1377 trusted_keys: default_trusted_keys(),
1378 enforcement: PluginTrustEnforcement::default(),
1379 }
1380 }
1381}
1382
1383impl PluginTrustConfig {
1384 pub fn is_host_trusted(&self, url: &str) -> bool {
1390 is_host_trusted(url, &self.trusted_hosts)
1391 }
1392
1393 pub fn enforcement_is_off(&self) -> bool {
1399 matches!(self.enforcement, PluginTrustEnforcement::Off)
1400 }
1401}
1402
1403#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1416#[serde(rename_all = "snake_case")]
1417pub enum PluginTrustEnforcement {
1418 #[default]
1423 Strict,
1424 Off,
1428}
1429
1430impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1431 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1432 where
1433 D: serde::Deserializer<'de>,
1434 {
1435 #[derive(Deserialize)]
1436 #[serde(untagged)]
1437 enum Repr {
1438 Bool(bool),
1439 Str(String),
1440 }
1441 match Repr::deserialize(deserializer)? {
1442 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1443 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1444 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1445 "strict" => Ok(PluginTrustEnforcement::Strict),
1446 "off" => Ok(PluginTrustEnforcement::Off),
1447 other => Err(serde::de::Error::custom(format!(
1448 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1449 \"off\""
1450 ))),
1451 },
1452 }
1453 }
1454}
1455
1456struct TrustedHostEntry<'a> {
1461 host: &'a str,
1462 path_prefix: &'a str,
1463}
1464
1465fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1469 match entry.find('/') {
1470 Some(index) => TrustedHostEntry {
1471 host: &entry[..index],
1472 path_prefix: &entry[index..],
1473 },
1474 None => TrustedHostEntry {
1475 host: entry,
1476 path_prefix: "",
1477 },
1478 }
1479}
1480
1481fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1493 if prefix.is_empty() || path == prefix {
1494 return true;
1495 }
1496 if prefix.ends_with('/') {
1497 return path.starts_with(prefix);
1498 }
1499 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1500}
1501
1502pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1526 let Ok(parsed) = url::Url::parse(url) else {
1527 return false;
1528 };
1529 if parsed.scheme() != "https" {
1530 return false;
1531 }
1532 let Some(host) = parsed.host_str() else {
1533 return false;
1534 };
1535 let host = host.to_ascii_lowercase();
1536 let path = parsed.path();
1537
1538 trusted_hosts.iter().any(|raw_entry| {
1539 let entry = raw_entry.trim().to_ascii_lowercase();
1540 let parsed_entry = parse_trusted_host_entry(&entry);
1541 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1542 })
1543}
1544
1545#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1554#[serde(rename_all = "snake_case")]
1555pub enum ContextManagementStrategy {
1556 #[default]
1557 Summary,
1558 MessageSummary,
1559 RetrievalWindow,
1560}
1561
1562#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1565#[serde(rename_all = "snake_case")]
1566pub enum ContextManagementFallbackStrategy {
1567 #[default]
1568 None,
1569 Summary,
1570}
1571
1572fn default_retrieval_window_min_recent_user_turns() -> usize {
1573 3
1574}
1575
1576fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1577 0.80
1578}
1579
1580fn default_retrieval_window_target_usage_ratio() -> f64 {
1581 0.60
1582}
1583
1584fn default_history_tool_required() -> bool {
1585 true
1586}
1587
1588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1590#[serde(default)]
1591pub struct RetrievalWindowContextConfig {
1592 #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1593 pub min_recent_user_turns: usize,
1594 #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1595 pub trigger_usage_ratio: f64,
1596 #[serde(default = "default_retrieval_window_target_usage_ratio")]
1597 pub target_usage_ratio: f64,
1598 #[serde(default = "default_history_tool_required")]
1599 pub history_tool_required: bool,
1600 #[serde(default)]
1601 pub fallback_strategy: ContextManagementFallbackStrategy,
1602}
1603
1604impl Default for RetrievalWindowContextConfig {
1605 fn default() -> Self {
1606 Self {
1607 min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1608 trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1609 target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1610 history_tool_required: default_history_tool_required(),
1611 fallback_strategy: ContextManagementFallbackStrategy::None,
1612 }
1613 }
1614}
1615
1616#[derive(Debug, Clone, Serialize, PartialEq)]
1618pub struct ContextManagementConfig {
1619 pub strategy: ContextManagementStrategy,
1620 pub retrieval_window: RetrievalWindowContextConfig,
1621}
1622
1623impl Default for ContextManagementConfig {
1624 fn default() -> Self {
1625 Self {
1626 strategy: ContextManagementStrategy::Summary,
1627 retrieval_window: RetrievalWindowContextConfig::default(),
1628 }
1629 }
1630}
1631
1632impl ContextManagementConfig {
1633 pub fn is_default(&self) -> bool {
1634 *self == Self::default()
1635 }
1636
1637 pub fn validate(&self) -> std::result::Result<(), String> {
1638 let policy = &self.retrieval_window;
1639 if policy.min_recent_user_turns == 0 {
1640 return Err(
1641 "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1642 .to_string(),
1643 );
1644 }
1645 if !policy.target_usage_ratio.is_finite()
1646 || !policy.trigger_usage_ratio.is_finite()
1647 || policy.target_usage_ratio < 0.01
1648 || policy.target_usage_ratio >= policy.trigger_usage_ratio
1649 || policy.trigger_usage_ratio > 1.0
1650 {
1651 return Err(
1652 "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1653 .to_string(),
1654 );
1655 }
1656 if self.strategy == ContextManagementStrategy::RetrievalWindow
1657 && !policy.history_tool_required
1658 {
1659 return Err(
1660 "retrieval_window requires history_tool_required=true in this release".to_string(),
1661 );
1662 }
1663 Ok(())
1664 }
1665
1666 pub fn retrieval_target_usage_percent(&self) -> u8 {
1671 ((self.retrieval_window.target_usage_ratio * 100.0).floor() as u8).max(1)
1672 }
1673}
1674
1675impl<'de> Deserialize<'de> for ContextManagementConfig {
1676 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1677 where
1678 D: serde::Deserializer<'de>,
1679 {
1680 #[derive(Deserialize)]
1681 #[serde(default)]
1682 struct Wire {
1683 strategy: ContextManagementStrategy,
1684 retrieval_window: RetrievalWindowContextConfig,
1685 }
1686
1687 impl Default for Wire {
1688 fn default() -> Self {
1689 let defaults = ContextManagementConfig::default();
1690 Self {
1691 strategy: defaults.strategy,
1692 retrieval_window: defaults.retrieval_window,
1693 }
1694 }
1695 }
1696
1697 let wire = Wire::deserialize(deserializer)?;
1698 let config = Self {
1699 strategy: wire.strategy,
1700 retrieval_window: wire.retrieval_window,
1701 };
1702 config.validate().map_err(serde::de::Error::custom)?;
1703 Ok(config)
1704 }
1705}
1706
1707#[derive(Debug, Clone, Serialize, Deserialize)]
1712#[doc(hidden)]
1713pub struct ConfigValues {
1714 #[serde(default)]
1716 pub http_proxy: String,
1717 #[serde(default)]
1719 pub https_proxy: String,
1720 #[serde(skip_serializing)]
1724 pub proxy_auth: Option<ProxyAuth>,
1725 #[serde(default, skip_serializing_if = "Option::is_none")]
1727 pub proxy_auth_encrypted: Option<String>,
1728 #[serde(default, skip_serializing_if = "Option::is_none")]
1730 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1731 #[serde(default)]
1733 pub headless_auth: bool,
1734
1735 #[serde(default = "default_provider")]
1737 pub provider: String,
1738
1739 #[serde(default, skip_serializing_if = "Option::is_none")]
1741 pub defaults: Option<DefaultsConfig>,
1742
1743 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1750 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1751
1752 #[serde(default, skip_serializing_if = "Option::is_none")]
1756 pub default_provider_instance: Option<String>,
1757
1758 #[serde(default)]
1760 pub server: ServerConfig,
1761
1762 #[serde(default)]
1766 pub keyword_masking: KeywordMaskingConfig,
1767
1768 #[serde(default)]
1772 pub anthropic_model_mapping: AnthropicModelMapping,
1773
1774 #[serde(default)]
1778 pub gemini_model_mapping: GeminiModelMapping,
1779
1780 #[serde(default)]
1785 pub hooks: HooksConfig,
1786
1787 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1793 pub lifecycle_hooks: LifecycleHooksConfig,
1794
1795 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1799 pub tools: ToolsConfig,
1800
1801 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1806 pub skills: SkillsConfig,
1807
1808 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1812 pub env_vars: Vec<EnvVarEntry>,
1813
1814 #[serde(default, skip_serializing_if = "Option::is_none")]
1816 pub default_work_area: Option<DefaultWorkAreaConfig>,
1817
1818 #[serde(default, skip_serializing_if = "Option::is_none")]
1820 pub access_control: Option<AccessControlConfig>,
1821
1822 #[serde(default)]
1824 pub features: FeatureFlags,
1825
1826 #[serde(default)]
1831 pub run_budget: RunBudgetConfig,
1832
1833 #[serde(default)]
1835 pub stream_timeout: StreamTimeoutConfig,
1836
1837 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1839 pub context_management: ContextManagementConfig,
1840
1841 #[serde(
1845 default,
1846 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1847 )]
1848 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1849
1850 #[serde(default, rename = "mcpServers", alias = "mcp")]
1856 pub mcp: bamboo_domain::mcp_config::McpConfig,
1857
1858 #[serde(default)]
1862 pub notifications: NotificationsConfig,
1863
1864 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1869 pub connect: ConnectConfig,
1870
1871 #[serde(default)]
1875 pub plugin_trust: PluginTrustConfig,
1876
1877 #[serde(default, flatten)]
1883 pub extra: BTreeMap<String, Value>,
1884}
1885
1886impl Default for ConfigValues {
1887 fn default() -> Self {
1888 Self {
1889 http_proxy: String::new(),
1890 https_proxy: String::new(),
1891 proxy_auth: None,
1892 proxy_auth_encrypted: None,
1893 proxy_auth_credential_ref: None,
1894 headless_auth: false,
1895 run_budget: RunBudgetConfig::default(),
1896 stream_timeout: StreamTimeoutConfig::default(),
1897 context_management: ContextManagementConfig::default(),
1898 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1899 provider: default_provider(),
1900 provider_instances: HashMap::new(),
1901 default_provider_instance: None,
1902 server: ServerConfig::default(),
1903 keyword_masking: KeywordMaskingConfig::default(),
1904 anthropic_model_mapping: AnthropicModelMapping::default(),
1905 gemini_model_mapping: GeminiModelMapping::default(),
1906 hooks: HooksConfig::default(),
1907 lifecycle_hooks: LifecycleHooksConfig::default(),
1908 tools: ToolsConfig::default(),
1909 skills: SkillsConfig::default(),
1910 env_vars: Vec::new(),
1911 default_work_area: None,
1912 access_control: None,
1913 features: FeatureFlags::default(),
1914 defaults: None,
1915 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1916 notifications: NotificationsConfig::default(),
1917 connect: ConnectConfig::default(),
1918 plugin_trust: PluginTrustConfig::default(),
1919 extra: BTreeMap::new(),
1920 }
1921 }
1922}
1923
1924#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1927struct NetworkConfigSection {
1928 #[serde(default)]
1929 http_proxy: String,
1930 #[serde(default)]
1931 https_proxy: String,
1932 #[serde(skip_serializing)]
1933 proxy_auth: Option<ProxyAuth>,
1934 #[serde(default, skip_serializing_if = "Option::is_none")]
1935 proxy_auth_encrypted: Option<String>,
1936 #[serde(default, skip_serializing_if = "Option::is_none")]
1937 proxy_auth_credential_ref: Option<crate::CredentialRef>,
1938 #[serde(default)]
1939 headless_auth: bool,
1940 #[serde(default)]
1941 server: ServerConfig,
1942}
1943
1944#[derive(Debug, Clone, Serialize, Deserialize)]
1945struct ProviderRoutingConfigSection {
1946 #[serde(default = "default_provider")]
1947 provider: String,
1948 #[serde(default, skip_serializing_if = "Option::is_none")]
1949 defaults: Option<DefaultsConfig>,
1950 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1951 provider_instances: HashMap<String, ProviderInstanceConfig>,
1952 #[serde(default, skip_serializing_if = "Option::is_none")]
1953 default_provider_instance: Option<String>,
1954}
1955
1956impl Default for ProviderRoutingConfigSection {
1957 fn default() -> Self {
1958 Self {
1959 provider: default_provider(),
1960 defaults: None,
1961 provider_instances: HashMap::new(),
1962 default_provider_instance: None,
1963 }
1964 }
1965}
1966
1967#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1968struct ModelBehaviorConfigSection {
1969 #[serde(default)]
1970 keyword_masking: KeywordMaskingConfig,
1971 #[serde(default)]
1972 anthropic_model_mapping: AnthropicModelMapping,
1973 #[serde(default)]
1974 gemini_model_mapping: GeminiModelMapping,
1975 #[serde(default)]
1976 hooks: HooksConfig,
1977 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1978 lifecycle_hooks: LifecycleHooksConfig,
1979}
1980
1981#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1982struct ToolingConfigSection {
1983 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1984 tools: ToolsConfig,
1985 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1986 skills: SkillsConfig,
1987 #[serde(default, rename = "mcpServers", alias = "mcp")]
1988 mcp: bamboo_domain::mcp_config::McpConfig,
1989}
1990
1991#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1992struct WorkspaceConfigSection {
1993 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1994 env_vars: Vec<EnvVarEntry>,
1995 #[serde(default, skip_serializing_if = "Option::is_none")]
1996 default_work_area: Option<DefaultWorkAreaConfig>,
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
1998 access_control: Option<AccessControlConfig>,
1999}
2000
2001#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2002struct ExecutionConfigSection {
2003 #[serde(default)]
2004 features: FeatureFlags,
2005 #[serde(default)]
2006 run_budget: RunBudgetConfig,
2007 #[serde(default)]
2008 stream_timeout: StreamTimeoutConfig,
2009 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2010 context_management: ContextManagementConfig,
2011 #[serde(
2012 default,
2013 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2014 )]
2015 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2016}
2017
2018#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2019struct IntegrationConfigSection {
2020 #[serde(default)]
2021 notifications: NotificationsConfig,
2022 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2023 connect: ConnectConfig,
2024}
2025
2026#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2027struct PluginSecurityConfigSection {
2028 #[serde(default)]
2029 plugin_trust: PluginTrustConfig,
2030}
2031
2032macro_rules! count_fields {
2035 ($($field:ident),* $(,)?) => {
2036 <[()]>::len(&[$(count_fields!(@one $field)),*])
2037 };
2038 (@one $field:ident) => { () };
2039}
2040
2041macro_rules! define_counted_struct {
2042 (
2043 $(#[$struct_meta:meta])*
2044 $visibility:vis struct $name:ident {
2045 $(
2046 $(#[$field_meta:meta])*
2047 $field_visibility:vis $field:ident: $field_type:ty
2048 ),* $(,)?
2049 }
2050 count $count_visibility:vis $count_name:ident;
2051 ) => {
2052 $(#[$struct_meta])*
2053 $visibility struct $name {
2054 $(
2055 $(#[$field_meta])*
2056 $field_visibility $field: $field_type,
2057 )*
2058 }
2059
2060 $count_visibility const $count_name: usize = count_fields!($($field),*);
2061 };
2062}
2063
2064define_counted_struct! {
2065 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2071 struct ConfigRoot {
2072 #[serde(flatten)]
2073 network: NetworkConfigSection,
2074 #[serde(flatten)]
2075 provider_routing: ProviderRoutingConfigSection,
2076 #[serde(flatten)]
2077 model_behavior: ModelBehaviorConfigSection,
2078 #[serde(flatten)]
2079 tooling: ToolingConfigSection,
2080 #[serde(flatten)]
2081 workspace: WorkspaceConfigSection,
2082 #[serde(flatten)]
2083 execution: ExecutionConfigSection,
2084 #[serde(flatten)]
2085 integrations: IntegrationConfigSection,
2086 #[serde(flatten)]
2087 plugin_security: PluginSecurityConfigSection,
2088 #[serde(default, flatten)]
2089 extra: BTreeMap<String, Value>,
2090 }
2091 count pub PERSISTED_ROOT_FIELD_COUNT;
2092}
2093
2094impl From<ConfigValues> for ConfigRoot {
2095 fn from(values: ConfigValues) -> Self {
2096 let ConfigValues {
2099 http_proxy,
2100 https_proxy,
2101 proxy_auth,
2102 proxy_auth_encrypted,
2103 proxy_auth_credential_ref,
2104 headless_auth,
2105 provider,
2106 defaults,
2107 provider_instances,
2108 default_provider_instance,
2109 server,
2110 keyword_masking,
2111 anthropic_model_mapping,
2112 gemini_model_mapping,
2113 hooks,
2114 lifecycle_hooks,
2115 tools,
2116 skills,
2117 env_vars,
2118 default_work_area,
2119 access_control,
2120 features,
2121 run_budget,
2122 stream_timeout,
2123 context_management,
2124 cluster_fabric,
2125 mcp,
2126 notifications,
2127 connect,
2128 plugin_trust,
2129 extra,
2130 } = values;
2131
2132 Self {
2133 network: NetworkConfigSection {
2134 http_proxy,
2135 https_proxy,
2136 proxy_auth,
2137 proxy_auth_encrypted,
2138 proxy_auth_credential_ref,
2139 headless_auth,
2140 server,
2141 },
2142 provider_routing: ProviderRoutingConfigSection {
2143 provider,
2144 defaults,
2145 provider_instances,
2146 default_provider_instance,
2147 },
2148 model_behavior: ModelBehaviorConfigSection {
2149 keyword_masking,
2150 anthropic_model_mapping,
2151 gemini_model_mapping,
2152 hooks,
2153 lifecycle_hooks,
2154 },
2155 tooling: ToolingConfigSection { tools, skills, mcp },
2156 workspace: WorkspaceConfigSection {
2157 env_vars,
2158 default_work_area,
2159 access_control,
2160 },
2161 execution: ExecutionConfigSection {
2162 features,
2163 run_budget,
2164 stream_timeout,
2165 context_management,
2166 cluster_fabric,
2167 },
2168 integrations: IntegrationConfigSection {
2169 notifications,
2170 connect,
2171 },
2172 plugin_security: PluginSecurityConfigSection { plugin_trust },
2173 extra,
2174 }
2175 }
2176}
2177
2178impl From<ConfigRoot> for ConfigValues {
2179 fn from(root: ConfigRoot) -> Self {
2180 let ConfigRoot {
2183 network,
2184 provider_routing,
2185 model_behavior,
2186 tooling,
2187 workspace,
2188 execution,
2189 integrations,
2190 plugin_security,
2191 extra,
2192 } = root;
2193 let NetworkConfigSection {
2194 http_proxy,
2195 https_proxy,
2196 proxy_auth,
2197 proxy_auth_encrypted,
2198 proxy_auth_credential_ref,
2199 headless_auth,
2200 server,
2201 } = network;
2202 let ProviderRoutingConfigSection {
2203 provider,
2204 defaults,
2205 provider_instances,
2206 default_provider_instance,
2207 } = provider_routing;
2208 let ModelBehaviorConfigSection {
2209 keyword_masking,
2210 anthropic_model_mapping,
2211 gemini_model_mapping,
2212 hooks,
2213 lifecycle_hooks,
2214 } = model_behavior;
2215 let ToolingConfigSection { tools, skills, mcp } = tooling;
2216 let WorkspaceConfigSection {
2217 env_vars,
2218 default_work_area,
2219 access_control,
2220 } = workspace;
2221 let ExecutionConfigSection {
2222 features,
2223 run_budget,
2224 stream_timeout,
2225 context_management,
2226 cluster_fabric,
2227 } = execution;
2228 let IntegrationConfigSection {
2229 notifications,
2230 connect,
2231 } = integrations;
2232 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2233
2234 Self {
2235 http_proxy,
2236 https_proxy,
2237 proxy_auth,
2238 proxy_auth_encrypted,
2239 proxy_auth_credential_ref,
2240 headless_auth,
2241 provider,
2242 defaults,
2243 provider_instances,
2244 default_provider_instance,
2245 server,
2246 keyword_masking,
2247 anthropic_model_mapping,
2248 gemini_model_mapping,
2249 hooks,
2250 lifecycle_hooks,
2251 tools,
2252 skills,
2253 env_vars,
2254 default_work_area,
2255 access_control,
2256 features,
2257 run_budget,
2258 stream_timeout,
2259 context_management,
2260 cluster_fabric,
2261 mcp,
2262 notifications,
2263 connect,
2264 plugin_trust,
2265 extra,
2266 }
2267 }
2268}
2269
2270fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2277 let instance_native = values
2278 .default_provider_instance
2279 .as_ref()
2280 .is_some_and(|id| values.provider_instances.contains_key(id));
2281 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2282 if instance_native {
2283 if let Some(object) = value.as_object_mut() {
2284 object.remove("provider");
2285 object.remove("providers");
2286 }
2287 }
2288 Ok(value)
2289}
2290
2291define_counted_struct! {
2292 #[derive(Debug, Clone)]
2295 pub struct Config {
2296 values: ConfigValues,
2297 pub(crate) memory: crate::MemoryConfigModule,
2298 pub(crate) subagents: crate::SubagentsConfigModule,
2299 pub(crate) providers: crate::ProviderConfigsModule,
2300 recovery_status: Option<ConfigRecoveryStatus>,
2301 }
2302 count pub CONFIG_FIELD_COUNT;
2303}
2304
2305pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2307const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2308const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2309
2310impl std::ops::Deref for Config {
2311 type Target = ConfigValues;
2312
2313 fn deref(&self) -> &Self::Target {
2314 &self.values
2315 }
2316}
2317
2318impl std::ops::DerefMut for Config {
2319 fn deref_mut(&mut self) -> &mut Self::Target {
2320 &mut self.values
2321 }
2322}
2323
2324impl Serialize for Config {
2325 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2326 where
2327 S: serde::Serializer,
2328 {
2329 self.to_compatibility_value()
2330 .map_err(serde::ser::Error::custom)?
2331 .serialize(serializer)
2332 }
2333}
2334
2335impl<'de> Deserialize<'de> for Config {
2336 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2337 where
2338 D: serde::Deserializer<'de>,
2339 {
2340 use serde::de::Error;
2341
2342 let mut value = Value::deserialize(deserializer)?;
2343 let object = value
2344 .as_object_mut()
2345 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2346 let memory = object
2347 .remove("memory")
2348 .map(serde_json::from_value)
2349 .transpose()
2350 .map_err(D::Error::custom)?
2351 .unwrap_or_default();
2352 let subagents = object
2353 .remove("subagents")
2354 .map(serde_json::from_value)
2355 .transpose()
2356 .map_err(D::Error::custom)?
2357 .unwrap_or_default();
2358 let providers = object
2359 .remove("providers")
2360 .map(serde_json::from_value)
2361 .transpose()
2362 .map_err(D::Error::custom)?
2363 .unwrap_or_default();
2364 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2365
2366 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2367 }
2368}
2369
2370#[derive(Debug, Clone, PartialEq, Serialize)]
2372#[serde(tag = "kind", rename_all = "snake_case")]
2373pub enum ConfigRecoverySource {
2374 Salvaged { fields: Vec<String> },
2379 Backup { generation: usize },
2382 Defaults,
2384}
2385
2386#[derive(Debug, Clone, PartialEq, Serialize)]
2398pub struct ConfigRecoveryStatus {
2399 pub source: ConfigRecoverySource,
2401 pub quarantine_path: Option<PathBuf>,
2407 pub confirmed: bool,
2410}
2411
2412#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2416pub struct ProviderConfigs {
2417 #[serde(skip_serializing_if = "Option::is_none")]
2419 pub openai: Option<OpenAIConfig>,
2420 #[serde(skip_serializing_if = "Option::is_none")]
2422 pub anthropic: Option<AnthropicConfig>,
2423 #[serde(skip_serializing_if = "Option::is_none")]
2425 pub gemini: Option<GeminiConfig>,
2426 #[serde(skip_serializing_if = "Option::is_none")]
2428 pub copilot: Option<CopilotConfig>,
2429 #[serde(skip_serializing_if = "Option::is_none")]
2431 pub bodhi: Option<BodhiConfig>,
2432
2433 #[serde(default, flatten)]
2435 pub extra: BTreeMap<String, Value>,
2436}
2437
2438impl ProviderConfigs {
2439 pub fn clear_legacy_builtin_aliases(&mut self) {
2442 self.openai = None;
2443 self.anthropic = None;
2444 self.gemini = None;
2445 self.copilot = None;
2446 self.bodhi = None;
2447 self.extra.remove("provider");
2448 }
2449}
2450
2451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2453pub struct FeatureFlags {
2454 #[serde(default)]
2456 pub ticket_mutation: bool,
2457 #[serde(default)]
2459 pub ticket_dispatch: bool,
2460 #[serde(default)]
2462 pub provider_model_ref: bool,
2463 #[serde(default)]
2465 pub dynamic_model_routing: bool,
2466 #[serde(default)]
2468 pub final_evidence_check: bool,
2469}
2470
2471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2475pub struct DefaultsConfig {
2476 pub chat: bamboo_domain::ProviderModelRef,
2477 #[serde(default, skip_serializing_if = "Option::is_none")]
2478 pub fast: Option<bamboo_domain::ProviderModelRef>,
2479 #[serde(default, skip_serializing_if = "Option::is_none")]
2480 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2481 #[serde(default, skip_serializing_if = "Option::is_none")]
2482 pub vision: Option<bamboo_domain::ProviderModelRef>,
2483 #[serde(default, skip_serializing_if = "Option::is_none")]
2484 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2485 #[serde(default, skip_serializing_if = "Option::is_none")]
2488 pub planning: Option<bamboo_domain::ProviderModelRef>,
2489 #[serde(default, skip_serializing_if = "Option::is_none")]
2492 pub search: Option<bamboo_domain::ProviderModelRef>,
2493 #[serde(default, skip_serializing_if = "Option::is_none")]
2496 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2497 #[serde(
2500 default,
2501 skip_serializing_if = "Option::is_none",
2502 alias = "sub_session"
2503 )]
2504 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2505 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2509 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2510}
2511
2512#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2514pub struct HooksConfig {
2515 #[serde(default)]
2517 pub image_fallback: ImageFallbackHookConfig,
2518}
2519
2520pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2522pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2524pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2528
2529pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2531 "SessionStart",
2532 "UserPromptSubmit",
2533 "PreToolUse",
2534 "PostToolUse",
2535 "Stop",
2536 "SessionEnd",
2537 "PreCompact",
2538 "Notification",
2539];
2540
2541fn default_lifecycle_hook_timeout_ms() -> u64 {
2542 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2543}
2544
2545fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2546 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2547}
2548
2549fn lifecycle_hook_enabled_default() -> bool {
2550 true
2551}
2552
2553fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2554 *value
2555}
2556
2557#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2562pub struct LifecycleHooksConfig {
2563 #[serde(default)]
2564 pub enabled: bool,
2565 #[serde(
2566 default,
2567 rename = "SessionStart",
2568 skip_serializing_if = "Vec::is_empty"
2569 )]
2570 pub session_start: Vec<LifecycleHookGroup>,
2571 #[serde(
2572 default,
2573 rename = "UserPromptSubmit",
2574 skip_serializing_if = "Vec::is_empty"
2575 )]
2576 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2577 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2578 pub pre_tool_use: Vec<LifecycleHookGroup>,
2579 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2580 pub post_tool_use: Vec<LifecycleHookGroup>,
2581 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2582 pub stop: Vec<LifecycleHookGroup>,
2583 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2584 pub session_end: Vec<LifecycleHookGroup>,
2585 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2586 pub pre_compact: Vec<LifecycleHookGroup>,
2587 #[serde(
2588 default,
2589 rename = "Notification",
2590 skip_serializing_if = "Vec::is_empty"
2591 )]
2592 pub notification: Vec<LifecycleHookGroup>,
2593}
2594
2595impl LifecycleHooksConfig {
2596 pub fn is_empty(&self) -> bool {
2597 !self.enabled
2598 && self.session_start.is_empty()
2599 && self.user_prompt_submit.is_empty()
2600 && self.pre_tool_use.is_empty()
2601 && self.post_tool_use.is_empty()
2602 && self.stop.is_empty()
2603 && self.session_end.is_empty()
2604 && self.pre_compact.is_empty()
2605 && self.notification.is_empty()
2606 }
2607}
2608
2609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2611pub struct LifecycleHookGroup {
2612 #[serde(
2615 default = "lifecycle_hook_enabled_default",
2616 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2617 )]
2618 pub enabled: bool,
2619 #[serde(default, skip_serializing_if = "Option::is_none")]
2620 pub matcher: Option<String>,
2621 #[serde(default)]
2622 pub hooks: Vec<LifecycleHookHandler>,
2623}
2624
2625impl Default for LifecycleHookGroup {
2626 fn default() -> Self {
2627 Self {
2628 enabled: true,
2629 matcher: None,
2630 hooks: Vec::new(),
2631 }
2632 }
2633}
2634
2635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2640#[serde(tag = "type", rename_all = "lowercase")]
2641pub enum LifecycleHookHandler {
2642 Command {
2643 command: String,
2644 #[serde(
2645 default = "default_lifecycle_hook_timeout_ms",
2646 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2647 )]
2648 timeout_ms: u64,
2649 },
2650 Script {
2651 path: String,
2652 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2653 runner: LifecycleScriptRunner,
2654 #[serde(
2655 default = "default_lifecycle_hook_timeout_ms",
2656 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2657 )]
2658 timeout_ms: u64,
2659 },
2660}
2661
2662#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2668#[serde(rename_all = "lowercase")]
2669pub enum LifecycleScriptRunner {
2670 #[default]
2671 Auto,
2672 Node,
2673 Bun,
2674 Python,
2675 Bash,
2676 PowerShell,
2677 Cmd,
2678}
2679
2680impl LifecycleScriptRunner {
2681 pub fn is_auto(&self) -> bool {
2682 matches!(self, Self::Auto)
2683 }
2684
2685 pub fn as_str(self) -> &'static str {
2686 match self {
2687 Self::Auto => "auto",
2688 Self::Node => "node",
2689 Self::Bun => "bun",
2690 Self::Python => "python",
2691 Self::Bash => "bash",
2692 Self::PowerShell => "powershell",
2693 Self::Cmd => "cmd",
2694 }
2695 }
2696
2697 pub fn supports_path(self, path: &str) -> bool {
2699 let extension = lifecycle_script_extension(path);
2700 match self {
2701 Self::Auto => extension.is_some(),
2702 Self::Node | Self::Bun => {
2703 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2704 }
2705 Self::Python => matches!(extension.as_deref(), Some("py")),
2706 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2707 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2708 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2709 }
2710 }
2711}
2712
2713pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2716 let extension = std::path::Path::new(path)
2717 .extension()?
2718 .to_str()?
2719 .to_ascii_lowercase();
2720 matches!(
2721 extension.as_str(),
2722 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2723 )
2724 .then_some(extension)
2725}
2726
2727impl LifecycleHookHandler {
2728 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2729 Self::Command {
2730 command: command.into(),
2731 timeout_ms,
2732 }
2733 }
2734
2735 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2736 Self::Script {
2737 path: path.into(),
2738 runner,
2739 timeout_ms,
2740 }
2741 }
2742
2743 pub fn timeout_ms(&self) -> u64 {
2744 match self {
2745 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2746 }
2747 }
2748}
2749
2750#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2757pub struct RequestOverridesConfig {
2758 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2760 pub common: RequestScopeOverride,
2761 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2763 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2764 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2766 pub rules: Vec<ModelRequestRule>,
2767}
2768
2769#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2771pub struct ModelRequestRule {
2772 pub model_pattern: String,
2774 #[serde(default, skip_serializing_if = "Option::is_none")]
2776 pub endpoint: Option<String>,
2777 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2779 pub scope: RequestScopeOverride,
2780}
2781
2782#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2784pub struct RequestScopeOverride {
2785 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2787 pub headers: BTreeMap<String, TemplateExpr>,
2788 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2790 pub body_patch: Vec<BodyPatch>,
2791}
2792
2793impl RequestScopeOverride {
2794 pub fn is_empty(&self) -> bool {
2795 self.headers.is_empty() && self.body_patch.is_empty()
2796 }
2797}
2798
2799#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2801pub struct BodyPatch {
2802 pub path: String,
2804 #[serde(default)]
2806 pub op: BodyPatchOp,
2807 #[serde(default, skip_serializing_if = "Option::is_none")]
2809 pub value: Option<PatchValue>,
2810}
2811
2812#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2814#[serde(rename_all = "snake_case")]
2815pub enum BodyPatchOp {
2816 #[default]
2817 Set,
2818 Remove,
2819}
2820
2821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2823#[serde(untagged)]
2824pub enum PatchValue {
2825 Template(TemplateExpr),
2826 Json(Value),
2827}
2828
2829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2831#[serde(untagged)]
2832pub enum TemplateExpr {
2833 Literal(String),
2835 Structured(TemplateExprSpec),
2837}
2838
2839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2841#[serde(tag = "type", rename_all = "snake_case")]
2842pub enum TemplateExprSpec {
2843 Literal { value: String },
2845 EnvRef {
2847 name: String,
2848 #[serde(default, skip_serializing_if = "Option::is_none")]
2849 fallback: Option<String>,
2850 },
2851 Generated { generator: GeneratedValue },
2853 Format { template: String },
2855}
2856
2857#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2859#[serde(rename_all = "snake_case")]
2860pub enum GeneratedValue {
2861 Uuid,
2862 UnixMs,
2863}
2864
2865#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2867pub struct ToolsConfig {
2868 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2870 pub disabled: Vec<String>,
2871
2872 #[serde(default, flatten)]
2874 pub extra: BTreeMap<String, Value>,
2875}
2876
2877impl ToolsConfig {
2878 fn is_empty(&self) -> bool {
2879 self.disabled.is_empty() && self.extra.is_empty()
2880 }
2881}
2882
2883#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2885pub struct SkillsConfig {
2886 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2888 pub disabled: Vec<String>,
2889
2890 #[serde(default, flatten)]
2892 pub extra: BTreeMap<String, Value>,
2893}
2894
2895impl SkillsConfig {
2896 fn is_empty(&self) -> bool {
2897 self.disabled.is_empty() && self.extra.is_empty()
2898 }
2899}
2900
2901#[derive(Debug, Clone, Serialize, Deserialize)]
2906pub struct ImageFallbackHookConfig {
2907 #[serde(default = "default_true_hooks")]
2908 pub enabled: bool,
2909
2910 #[serde(default = "default_image_fallback_mode")]
2912 pub mode: String,
2913}
2914
2915impl Default for ImageFallbackHookConfig {
2916 fn default() -> Self {
2917 Self {
2918 enabled: default_true_hooks(),
2919 mode: default_image_fallback_mode(),
2920 }
2921 }
2922}
2923
2924fn default_image_fallback_mode() -> String {
2925 "placeholder".to_string()
2926}
2927
2928fn default_true_hooks() -> bool {
2929 false
2932}
2933
2934pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2946
2947#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2948pub struct OpenAIConfig {
2949 #[serde(default, skip_serializing)]
2953 pub api_key: String,
2954 #[serde(default, skip_serializing_if = "Option::is_none")]
2956 pub api_key_encrypted: Option<String>,
2957 #[serde(default, skip_serializing_if = "Option::is_none")]
2959 pub credential_ref: Option<crate::CredentialRef>,
2960 #[serde(skip)]
2965 pub api_key_from_env: bool,
2966 #[serde(skip_serializing_if = "Option::is_none")]
2968 pub base_url: Option<String>,
2969 #[serde(skip_serializing_if = "Option::is_none")]
2971 pub model: Option<String>,
2972 #[serde(default, skip_serializing_if = "Option::is_none")]
2975 pub fast_model: Option<String>,
2976 #[serde(default, skip_serializing_if = "Option::is_none")]
2979 pub vision_model: Option<String>,
2980 #[serde(skip_serializing_if = "Option::is_none")]
2982 pub reasoning_effort: Option<ReasoningEffort>,
2983
2984 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2991 pub responses_only_models: Vec<String>,
2992 #[serde(default, skip_serializing_if = "Option::is_none")]
2994 pub request_overrides: Option<RequestOverridesConfig>,
2995
2996 #[serde(default, flatten)]
2998 pub extra: BTreeMap<String, Value>,
2999}
3000
3001impl OpenAIConfig {
3002 pub fn explicit_prompt_cache_enabled(&self) -> bool {
3009 self.extra
3010 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3011 .and_then(Value::as_bool)
3012 .unwrap_or(true)
3013 }
3014}
3015
3016#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3028pub struct AnthropicConfig {
3029 #[serde(default, skip_serializing)]
3033 pub api_key: String,
3034 #[serde(default, skip_serializing_if = "Option::is_none")]
3036 pub api_key_encrypted: Option<String>,
3037 #[serde(default, skip_serializing_if = "Option::is_none")]
3039 pub credential_ref: Option<crate::CredentialRef>,
3040 #[serde(skip)]
3045 pub api_key_from_env: bool,
3046 #[serde(skip_serializing_if = "Option::is_none")]
3048 pub base_url: Option<String>,
3049 #[serde(skip_serializing_if = "Option::is_none")]
3051 pub model: Option<String>,
3052 #[serde(default, skip_serializing_if = "Option::is_none")]
3055 pub fast_model: Option<String>,
3056 #[serde(default, skip_serializing_if = "Option::is_none")]
3059 pub vision_model: Option<String>,
3060 #[serde(skip_serializing_if = "Option::is_none")]
3062 pub max_tokens: Option<u32>,
3063 #[serde(skip_serializing_if = "Option::is_none")]
3065 pub reasoning_effort: Option<ReasoningEffort>,
3066 #[serde(default, skip_serializing_if = "Option::is_none")]
3068 pub request_overrides: Option<RequestOverridesConfig>,
3069
3070 #[serde(default, skip_serializing_if = "Option::is_none")]
3087 pub thinking_replay_always: Option<bool>,
3088
3089 #[serde(default, flatten)]
3091 pub extra: BTreeMap<String, Value>,
3092}
3093
3094#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3105pub struct GeminiConfig {
3106 #[serde(default, skip_serializing)]
3110 pub api_key: String,
3111 #[serde(default, skip_serializing_if = "Option::is_none")]
3113 pub api_key_encrypted: Option<String>,
3114 #[serde(default, skip_serializing_if = "Option::is_none")]
3116 pub credential_ref: Option<crate::CredentialRef>,
3117 #[serde(skip)]
3122 pub api_key_from_env: bool,
3123 #[serde(skip_serializing_if = "Option::is_none")]
3125 pub base_url: Option<String>,
3126 #[serde(skip_serializing_if = "Option::is_none")]
3128 pub model: Option<String>,
3129 #[serde(default, skip_serializing_if = "Option::is_none")]
3132 pub fast_model: Option<String>,
3133 #[serde(default, skip_serializing_if = "Option::is_none")]
3136 pub vision_model: Option<String>,
3137 #[serde(skip_serializing_if = "Option::is_none")]
3139 pub reasoning_effort: Option<ReasoningEffort>,
3140 #[serde(default, skip_serializing_if = "Option::is_none")]
3142 pub request_overrides: Option<RequestOverridesConfig>,
3143
3144 #[serde(default, flatten)]
3146 pub extra: BTreeMap<String, Value>,
3147}
3148
3149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3161pub struct CopilotConfig {
3162 #[serde(default)]
3164 pub enabled: bool,
3165 #[serde(default)]
3167 pub headless_auth: bool,
3168 #[serde(skip_serializing_if = "Option::is_none")]
3170 pub model: Option<String>,
3171 #[serde(default, skip_serializing_if = "Option::is_none")]
3174 pub fast_model: Option<String>,
3175 #[serde(default, skip_serializing_if = "Option::is_none")]
3178 pub vision_model: Option<String>,
3179 #[serde(skip_serializing_if = "Option::is_none")]
3181 pub reasoning_effort: Option<ReasoningEffort>,
3182
3183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3192 pub responses_only_models: Vec<String>,
3193 #[serde(default, skip_serializing_if = "Option::is_none")]
3195 pub request_overrides: Option<RequestOverridesConfig>,
3196
3197 #[serde(default, flatten)]
3199 pub extra: BTreeMap<String, Value>,
3200}
3201
3202#[derive(Debug, Clone, Serialize, Deserialize)]
3207pub struct BodhiConfig {
3208 #[serde(default, skip_serializing)]
3210 pub api_key: String,
3211 #[serde(default, skip_serializing_if = "Option::is_none")]
3213 pub api_key_encrypted: Option<String>,
3214 #[serde(default, skip_serializing_if = "Option::is_none")]
3216 pub credential_ref: Option<crate::CredentialRef>,
3217 #[serde(skip_serializing_if = "Option::is_none")]
3219 pub base_url: Option<String>,
3220 #[serde(skip_serializing_if = "Option::is_none")]
3222 pub target_provider: Option<String>,
3223 #[serde(skip_serializing_if = "Option::is_none")]
3225 pub reasoning_effort: Option<ReasoningEffort>,
3226
3227 #[serde(default, flatten)]
3229 pub extra: BTreeMap<String, Value>,
3230}
3231
3232fn default_provider() -> String {
3234 "anthropic".to_string()
3235}
3236
3237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3269pub struct ProviderInstanceConfig {
3270 pub provider_type: String,
3275
3276 #[serde(default, skip_serializing_if = "Option::is_none")]
3278 pub label: Option<String>,
3279
3280 #[serde(default, skip_serializing)]
3282 pub api_key: String,
3283
3284 #[serde(default, skip_serializing_if = "Option::is_none")]
3287 pub api_key_encrypted: Option<String>,
3288
3289 #[serde(default, skip_serializing_if = "Option::is_none")]
3291 pub credential_ref: Option<crate::CredentialRef>,
3292
3293 #[serde(default, skip_serializing_if = "Option::is_none")]
3295 pub base_url: Option<String>,
3296
3297 #[serde(default, skip_serializing_if = "Option::is_none")]
3299 pub model: Option<String>,
3300
3301 #[serde(default, skip_serializing_if = "Option::is_none")]
3303 pub fast_model: Option<String>,
3304
3305 #[serde(default, skip_serializing_if = "Option::is_none")]
3307 pub vision_model: Option<String>,
3308
3309 #[serde(default, skip_serializing_if = "Option::is_none")]
3311 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3312
3313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3315 pub responses_only_models: Vec<String>,
3316
3317 #[serde(default, skip_serializing_if = "Option::is_none")]
3319 pub request_overrides: Option<RequestOverridesConfig>,
3320
3321 #[serde(default = "default_true")]
3324 pub enabled: bool,
3325
3326 #[serde(default, flatten)]
3328 pub extra: BTreeMap<String, Value>,
3329}
3330
3331fn default_true() -> bool {
3332 true
3333}
3334
3335fn default_port() -> u16 {
3337 9562
3338}
3339
3340fn default_bind() -> String {
3342 "127.0.0.1".to_string()
3343}
3344
3345fn default_workers() -> usize {
3347 10
3348}
3349
3350fn default_data_dir() -> PathBuf {
3352 super::paths::bamboo_dir()
3353}
3354
3355#[derive(Debug, Clone, Serialize, Deserialize)]
3357pub struct ServerConfig {
3358 #[serde(default = "default_port")]
3360 pub port: u16,
3361
3362 #[serde(default = "default_bind")]
3364 pub bind: String,
3365
3366 pub static_dir: Option<PathBuf>,
3368
3369 #[serde(default = "default_workers")]
3371 pub workers: usize,
3372
3373 #[serde(default, skip_serializing_if = "Option::is_none")]
3381 pub tls: Option<TlsConfig>,
3382
3383 #[serde(default, flatten)]
3385 pub extra: BTreeMap<String, Value>,
3386}
3387
3388impl Default for ServerConfig {
3389 fn default() -> Self {
3390 Self {
3391 port: default_port(),
3392 bind: default_bind(),
3393 static_dir: None,
3394 workers: default_workers(),
3395 tls: None,
3396 extra: BTreeMap::new(),
3397 }
3398 }
3399}
3400
3401#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3407pub struct TlsConfig {
3408 pub cert_file: PathBuf,
3410 pub key_file: PathBuf,
3412}
3413
3414#[derive(Debug, Clone, Serialize, Deserialize)]
3416pub struct ProxyAuth {
3417 pub username: String,
3419 pub password: String,
3421}
3422
3423fn parse_bool_env(value: &str) -> bool {
3427 matches!(
3428 value.trim().to_ascii_lowercase().as_str(),
3429 "1" | "true" | "yes" | "y" | "on"
3430 )
3431}
3432
3433fn expand_user_path(value: &str) -> PathBuf {
3434 let trimmed = value.trim();
3435 if let Some(rest) = trimmed.strip_prefix("~/") {
3436 if let Some(home) = dirs::home_dir() {
3437 return home.join(rest);
3438 }
3439 }
3440 PathBuf::from(trimmed)
3441}
3442
3443impl Default for Config {
3444 fn default() -> Self {
3445 Self::create_default()
3454 }
3455}
3456
3457#[derive(Debug, Clone, PartialEq, Eq)]
3459pub struct PromptSafeEnvVarEntry {
3460 pub name: String,
3461 pub secret: bool,
3462 pub description: Option<String>,
3463}
3464
3465static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3469
3470static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3471
3472fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3473 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3474}
3475
3476fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3477 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3478}
3479
3480impl Config {
3481 fn from_parts(
3482 values: ConfigValues,
3483 memory: Option<MemoryConfig>,
3484 subagents: SubagentsConfig,
3485 providers: ProviderConfigs,
3486 ) -> Self {
3487 Self {
3488 values,
3489 memory: crate::MemoryConfigModule(memory),
3490 subagents: crate::SubagentsConfigModule(subagents),
3491 providers: crate::ProviderConfigsModule(providers),
3492 recovery_status: None,
3493 }
3494 }
3495
3496 pub(crate) fn section_values(&self) -> ConfigValues {
3502 self.values.clone()
3503 }
3504
3505 pub(crate) fn from_section_parts(
3506 values: ConfigValues,
3507 memory: Option<MemoryConfig>,
3508 subagents: SubagentsConfig,
3509 providers: ProviderConfigs,
3510 ) -> Self {
3511 Self::from_parts(values, memory, subagents, providers)
3512 }
3513
3514 pub fn memory(&self) -> &Option<MemoryConfig> {
3516 &self.memory.0
3517 }
3518
3519 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3520 &mut self.memory.0
3521 }
3522
3523 pub fn subagents(&self) -> &SubagentsConfig {
3525 &self.subagents.0
3526 }
3527
3528 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3529 &mut self.subagents.0
3530 }
3531
3532 pub fn providers(&self) -> &ProviderConfigs {
3534 &self.providers.0
3535 }
3536
3537 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3538 &mut self.providers.0
3539 }
3540
3541 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3545 if self
3546 .default_provider_instance
3547 .as_ref()
3548 .is_some_and(|id| self.provider_instances.contains_key(id))
3549 {
3550 self.providers.0.clear_legacy_builtin_aliases();
3551 }
3552 }
3553
3554 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3560 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3561 let object = value
3562 .as_object_mut()
3563 .expect("ConfigRoot always serializes as a JSON object");
3564 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3565 object.insert(
3566 "subagents".to_string(),
3567 serde_json::to_value(self.subagents())?,
3568 );
3569 object.insert(
3570 "providers".to_string(),
3571 serde_json::to_value(self.providers())?,
3572 );
3573 Ok(value)
3574 }
3575
3576 pub fn new() -> Self {
3595 Self::from_data_dir(None)
3596 }
3597
3598 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3599 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3600 Ok(facade) => facade.effective_config(),
3601 Err(error) => {
3602 tracing::warn!(
3603 error = %error,
3604 "completed modular configuration is unavailable; refusing legacy-root fallback"
3605 );
3606 Self::create_default()
3607 }
3608 };
3609
3610 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3611 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3612 config.proxy_auth = None;
3613 }
3614 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3615 tracing::warn!(error = %error, "provider credential hydration unavailable");
3616 }
3617 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3618 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3619 }
3620 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3621 tracing::warn!(error = %error, "env credential hydration unavailable");
3622 for entry in &mut config.env_vars {
3623 if entry.secret {
3624 entry.value.clear();
3625 }
3626 }
3627 }
3628 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3629 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3630 config.clear_cluster_runtime_credentials();
3631 }
3632 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3633 tracing::warn!(error = %error, "notification credential hydration unavailable");
3634 config.notifications.ntfy.token = None;
3635 config.notifications.bark.device_key = None;
3636 }
3637 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3638 tracing::warn!(error = %error, "connect credential hydration unavailable");
3639 for platform in &mut config.connect.platforms {
3640 platform.token = None;
3641 platform.app_secret = None;
3642 }
3643 }
3644 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3645 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3646 config.clear_access_control_runtime_verifiers();
3647 }
3648 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3649 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3650 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3651 broker.token.clear();
3652 }
3653 }
3654 config.normalize_tool_settings();
3655 config.normalize_skill_settings();
3656 config.normalize_plugin_trust_settings();
3657 config.extra.remove("data_dir");
3658 if apply_env {
3659 config.apply_env_overrides();
3660 }
3661 if publish {
3662 config.publish_env_vars();
3663 }
3664 config
3665 }
3666
3667 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3676 let data_dir = data_dir
3678 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3679 .unwrap_or_else(default_data_dir);
3680
3681 match crate::modular_authority_boundary_present(&data_dir) {
3682 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3683 Ok(false) => {}
3684 Err(error) => {
3685 tracing::warn!(
3686 error = %error,
3687 "modular configuration marker is unavailable; refusing legacy-root fallback"
3688 );
3689 let mut config = Self::create_default();
3690 if apply_env {
3691 config.apply_env_overrides();
3692 }
3693 if publish {
3694 config.publish_env_vars();
3695 }
3696 return config;
3697 }
3698 }
3699
3700 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3705 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3706 .map_err(|error| {
3707 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3708 error
3709 })
3710 .is_ok();
3711 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3712 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3713 .map_err(|error| {
3714 tracing::warn!(error = %error, "cluster credential migration unavailable");
3715 error
3716 })
3717 .is_ok();
3718
3719 match crate::modular_authority_boundary_present(&data_dir) {
3724 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3725 Ok(false) => {}
3726 Err(error) => {
3727 tracing::warn!(
3728 error = %error,
3729 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3730 );
3731 let mut config = Self::create_default();
3732 if apply_env {
3733 config.apply_env_overrides();
3734 }
3735 if publish {
3736 config.publish_env_vars();
3737 }
3738 return config;
3739 }
3740 }
3741
3742 let config_path = data_dir.join("config.json");
3743
3744 let mut config = if config_path.exists() {
3745 if let Ok(content) = std::fs::read_to_string(&config_path) {
3746 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3747 tracing::warn!(
3757 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3758 e
3759 );
3760 let quarantine_path = quarantine_corrupt_config(&config_path);
3761 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3762 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3763 .or_else(|| {
3764 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3765 (cfg, ConfigRecoverySource::Backup { generation })
3766 })
3767 })
3768 .unwrap_or_else(|| {
3769 tracing::warn!(
3770 "Could not salvage and no usable config.json.bak; using defaults"
3771 );
3772 (Self::create_default(), ConfigRecoverySource::Defaults)
3773 });
3774 recovered.recovery_status = Some(ConfigRecoveryStatus {
3775 source,
3776 quarantine_path,
3777 confirmed: false,
3778 });
3779 recovered
3780 })
3781 } else {
3782 Self::create_default()
3783 }
3784 } else {
3785 Self::create_default()
3786 };
3787
3788 let mut memory_module = config.memory.clone();
3794 match memory_module.load_sync(&data_dir) {
3795 Ok(true) => config.memory = memory_module,
3796 Ok(false) => {}
3797 Err(error) => tracing::warn!(
3798 "Failed to load memory.json; using legacy config.json memory: {error}"
3799 ),
3800 }
3801 let mut subagents_module = config.subagents.clone();
3802 match subagents_module.load_sync(&data_dir) {
3803 Ok(true) => config.subagents = subagents_module,
3804 Ok(false) => {}
3805 Err(error) => tracing::warn!(
3806 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3807 ),
3808 }
3809 if provider_mcp_ready {
3810 let mut providers_module = config.providers.clone();
3811 match providers_module.load_sync(&data_dir) {
3812 Ok(true) => config.providers = providers_module,
3813 Ok(false) => {}
3814 Err(error) => tracing::warn!(
3815 "Failed to load providers.json; using legacy config.json providers: {error}"
3816 ),
3817 }
3818 }
3819
3820 config.hydrate_proxy_auth_from_encrypted();
3822 if provider_mcp_ready {
3823 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3824 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3825 config.proxy_auth = None;
3826 }
3827 }
3828 config.hydrate_provider_api_keys_from_encrypted();
3830 config.hydrate_provider_instance_api_keys_from_encrypted();
3832 config.hydrate_mcp_secrets_from_encrypted();
3834 if provider_mcp_ready {
3835 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3836 tracing::warn!(error = %error, "provider credential hydration unavailable");
3837 }
3838 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3839 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3840 }
3841 }
3842 config.hydrate_env_vars_from_encrypted();
3844 if provider_mcp_ready {
3845 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3846 tracing::warn!(error = %error, "env credential hydration unavailable");
3847 for entry in &mut config.env_vars {
3848 if entry.secret {
3849 entry.value.clear();
3850 }
3851 }
3852 }
3853 }
3854 if cluster_ready {
3859 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3860 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3861 config.clear_cluster_runtime_credentials();
3862 }
3863 } else {
3864 config.clear_cluster_runtime_credentials();
3865 }
3866 config.hydrate_broker_token_from_encrypted();
3868 config.hydrate_notifications_from_encrypted();
3870 if provider_mcp_ready {
3871 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3872 tracing::warn!(error = %error, "notification credential hydration unavailable");
3873 config.notifications.ntfy.token = None;
3874 config.notifications.bark.device_key = None;
3875 }
3876 } else {
3877 config.notifications.ntfy.token = None;
3882 config.notifications.bark.device_key = None;
3883 }
3884 config.merge_connect_config(&data_dir);
3889 scrub_legacy_connect_from_config_backups(&data_dir);
3899 config.hydrate_connect_platform_tokens_from_encrypted();
3901 if provider_mcp_ready {
3902 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3903 tracing::warn!(error = %error, "connect credential hydration unavailable");
3904 for platform in &mut config.connect.platforms {
3905 platform.token = None;
3906 platform.app_secret = None;
3907 }
3908 }
3909 }
3910 if provider_mcp_ready {
3911 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3912 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3913 config.clear_access_control_runtime_verifiers();
3914 }
3915 } else {
3916 config.clear_access_control_runtime_verifiers();
3917 }
3918 config.normalize_tool_settings();
3919 config.normalize_skill_settings();
3920 config.normalize_plugin_trust_settings();
3921
3922 config.extra.remove("data_dir");
3925
3926 if apply_env {
3930 config.apply_env_overrides();
3931 }
3932
3933 if publish {
3937 config.publish_env_vars();
3938 }
3939
3940 config
3941 }
3942
3943 fn apply_env_overrides(&mut self) {
3947 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3948 if let Ok(port) = port.parse() {
3949 self.server.port = port;
3950 }
3951 }
3952
3953 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3954 self.server.bind = bind;
3955 }
3956
3957 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3962 let provider = provider.trim().to_string();
3963 if !provider.is_empty() {
3964 self.provider = provider.clone();
3965 if !self.provider_instances.is_empty() {
3966 let selected = self
3967 .provider_instances
3968 .contains_key(&provider)
3969 .then_some(provider.clone())
3970 .or_else(|| {
3971 let mut matching = self
3972 .provider_instances
3973 .iter()
3974 .filter(|(_, instance)| {
3975 instance.enabled && instance.provider_type == provider
3976 })
3977 .map(|(id, _)| id.clone())
3978 .collect::<Vec<_>>();
3979 matching.sort();
3980 matching.into_iter().next()
3981 });
3982 self.default_provider_instance = selected;
3983 }
3984 }
3985 }
3986
3987 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
3988 self.headless_auth = parse_bool_env(&headless);
3989 }
3990
3991 if let Ok(project_prompt_injection) =
3992 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
3993 {
3994 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3995 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
3996 }
3997
3998 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
3999 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4000 memory.relevant_recall = parse_bool_env(&relevant_recall);
4001 }
4002
4003 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4004 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4005 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4006 }
4007
4008 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4009 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4010 memory.project_first_dream = parse_bool_env(&project_first_dream);
4011 }
4012
4013 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4020 let key = key.trim();
4021 if !key.is_empty() {
4022 if self.provider_instances.is_empty() {
4023 let openai = self
4024 .providers
4025 .openai
4026 .get_or_insert_with(OpenAIConfig::default);
4027 openai.api_key = key.to_string();
4028 openai.api_key_from_env = true;
4029 } else {
4030 self.apply_provider_instance_env_key("openai", key);
4031 }
4032 }
4033 }
4034 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4035 let key = key.trim();
4036 if !key.is_empty() {
4037 if self.provider_instances.is_empty() {
4038 let anthropic = self
4039 .providers
4040 .anthropic
4041 .get_or_insert_with(AnthropicConfig::default);
4042 anthropic.api_key = key.to_string();
4043 anthropic.api_key_from_env = true;
4044 } else {
4045 self.apply_provider_instance_env_key("anthropic", key);
4046 }
4047 }
4048 }
4049 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4050 let key = key.trim();
4051 if !key.is_empty() {
4052 if self.provider_instances.is_empty() {
4053 let gemini = self
4054 .providers
4055 .gemini
4056 .get_or_insert_with(GeminiConfig::default);
4057 gemini.api_key = key.to_string();
4058 gemini.api_key_from_env = true;
4059 } else {
4060 self.apply_provider_instance_env_key("gemini", key);
4061 }
4062 }
4063 }
4064 }
4065
4066 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4067 for instance in self.provider_instances.values_mut().filter(|instance| {
4068 instance.provider_type == provider_type
4069 && crate::provider_instance_api_key_from_env(instance)
4070 }) {
4071 instance.api_key = key.to_string();
4072 instance.api_key_encrypted = None;
4073 }
4074 }
4075
4076 pub fn apply_runtime_env_overrides(&mut self) {
4081 self.apply_env_overrides();
4082 }
4083
4084 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4090 Self::from_data_dir_impl(data_dir, true, true)
4091 }
4092
4093 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4097 Self::from_data_dir_impl(data_dir, false, true)
4098 }
4099
4100 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4107 Self::from_data_dir_impl(data_dir, false, false)
4108 }
4109
4110 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4138 let connect_path = data_dir.join("connect.json");
4139 match std::fs::read_to_string(&connect_path) {
4140 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4141 Ok(connect) => {
4142 let legacy_key_present = !connect_config_is_empty(&self.connect);
4143 if legacy_key_present {
4144 tracing::warn!(
4145 "config.json still has a legacy `connect` key alongside \
4146 connect.json; connect.json takes precedence — dropping the \
4147 stale key from config.json now"
4148 );
4149 }
4150 self.connect = connect;
4151 if legacy_key_present {
4152 strip_legacy_connect_key_from_config_json(data_dir);
4153 }
4154 }
4155 Err(e) => {
4156 tracing::error!(
4157 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4158 connect config instead of falling back to a legacy config.json copy",
4159 connect_path,
4160 e
4161 );
4162 quarantine_corrupt_connect(&connect_path);
4163 self.connect = ConnectConfig::default();
4164 }
4165 },
4166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4167 if !connect_config_is_empty(&self.connect) {
4168 tracing::info!(
4169 "Migrating legacy `connect` config from config.json to a \
4170 standalone connect.json"
4171 );
4172 strip_legacy_connect_key_from_config_json(data_dir);
4176 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4177 tracing::error!("Failed to write connect.json during migration: {}", e);
4178 }
4179 }
4180 }
4181 Err(e) => {
4182 tracing::error!(
4183 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4184 connect_path,
4185 e
4186 );
4187 self.connect = ConnectConfig::default();
4188 }
4189 }
4190 }
4191
4192 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4195 serde_json::from_str::<Config>(content).map(|mut config| {
4196 config.hydrate_proxy_auth_from_encrypted();
4197 config.hydrate_provider_api_keys_from_encrypted();
4198 config.hydrate_provider_instance_api_keys_from_encrypted();
4199 config.hydrate_mcp_secrets_from_encrypted();
4200 config.hydrate_env_vars_from_encrypted();
4201 config.hydrate_cluster_fabric_from_encrypted();
4202 config.hydrate_broker_token_from_encrypted();
4203 config.hydrate_notifications_from_encrypted();
4204 config.hydrate_connect_platform_tokens_from_encrypted();
4205 config.normalize_tool_settings();
4206 config.normalize_skill_settings();
4207 config
4208 })
4209 }
4210
4211 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4217 let config_path = data_dir.join("config.json");
4218 for gen in 0..BAK_GENERATIONS {
4219 let backup = backup_path_for(&config_path, gen);
4220 let Ok(content) = std::fs::read_to_string(&backup) else {
4221 continue;
4222 };
4223 match Self::parse_and_hydrate(&content) {
4224 Ok(config) => {
4225 tracing::info!("Recovered configuration from {:?}", backup);
4226 return Some((config, gen));
4227 }
4228 Err(e) => {
4229 tracing::warn!(
4230 "Backup {:?} is unparseable ({}); trying an older generation",
4231 backup,
4232 e
4233 );
4234 }
4235 }
4236 }
4237 None
4238 }
4239
4240 const SALVAGE_MAX_KEYS: usize = 512;
4246
4247 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4274 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4277 let corrupt_obj = corrupt.as_object()?;
4278 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4279 tracing::warn!(
4280 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4281 corrupt_obj.len(),
4282 Self::SALVAGE_MAX_KEYS
4283 );
4284 return None;
4285 }
4286
4287 let mut base = Self::load_backup(data_dir)
4291 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4292 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4293 let base_obj = base.as_object_mut()?;
4294
4295 let mut salvaged: Vec<String> = Vec::new();
4296 for (key, value) in corrupt_obj {
4297 let previous = base_obj.insert(key.clone(), value.clone());
4298 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4302 salvaged.push(key.clone());
4303 } else {
4304 match previous {
4305 Some(prev) => {
4306 base_obj.insert(key.clone(), prev);
4307 }
4308 None => {
4309 base_obj.remove(key);
4310 }
4311 }
4312 }
4313 }
4314
4315 tracing::warn!(
4316 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4317 last-known-good/default value",
4318 salvaged.len(),
4319 salvaged.join(", ")
4320 );
4321
4322 let rebuilt = serde_json::to_string(&base).ok()?;
4326 Self::parse_and_hydrate(&rebuilt)
4327 .ok()
4328 .map(|config| (config, salvaged))
4329 }
4330
4331 pub fn get_model(&self) -> Option<String> {
4339 if self.features.provider_model_ref {
4340 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4341 return Some(model_ref.model.clone());
4342 }
4343 }
4344 let provider = self.effective_default_provider();
4345 if let Some(instance) = self.provider_instances.get(provider) {
4346 return instance
4347 .model
4348 .clone()
4349 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4350 }
4351 match provider {
4352 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4353 "anthropic" => self
4354 .providers
4355 .anthropic
4356 .as_ref()
4357 .and_then(|c| c.model.clone()),
4358 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4359 "copilot" => Some(
4360 self.providers
4361 .copilot
4362 .as_ref()
4363 .and_then(|c| c.model.clone())
4364 .unwrap_or_else(|| "gpt-4o".to_string()),
4365 ),
4366 _ => None,
4367 }
4368 }
4369
4370 pub fn get_fast_model(&self) -> Option<String> {
4378 if self.features.provider_model_ref {
4379 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4380 return Some(model_ref.model.clone());
4381 }
4382 }
4383 let provider = self.effective_default_provider();
4384 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4385 instance.fast_model.clone()
4386 } else {
4387 match provider {
4388 "openai" => self
4389 .providers
4390 .openai
4391 .as_ref()
4392 .and_then(|c| c.fast_model.clone()),
4393 "anthropic" => self
4394 .providers
4395 .anthropic
4396 .as_ref()
4397 .and_then(|c| c.fast_model.clone()),
4398 "gemini" => self
4399 .providers
4400 .gemini
4401 .as_ref()
4402 .and_then(|c| c.fast_model.clone()),
4403 "copilot" => self
4404 .providers
4405 .copilot
4406 .as_ref()
4407 .and_then(|c| c.fast_model.clone()),
4408 _ => None,
4409 }
4410 };
4411 fast.or_else(|| self.get_model())
4412 }
4413
4414 pub fn get_task_summary_model(&self) -> Option<String> {
4422 if self.features.provider_model_ref {
4423 if let Some(model_ref) = self
4424 .defaults
4425 .as_ref()
4426 .and_then(|d| d.task_summary.as_ref())
4427 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4428 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4429 {
4430 return Some(model_ref.model.clone());
4431 }
4432 }
4433
4434 self.get_fast_model()
4435 }
4436
4437 pub fn get_memory_background_model(&self) -> Option<String> {
4445 if self.features.provider_model_ref {
4446 if let Some(model_ref) = self
4447 .defaults
4448 .as_ref()
4449 .and_then(|d| d.memory_background.as_ref())
4450 {
4451 return Some(model_ref.model.clone());
4452 }
4453 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4454 return Some(model_ref.model.clone());
4455 }
4456 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4457 return Some(model_ref.model.clone());
4458 }
4459 }
4460 let configured = self
4461 .memory
4462 .as_ref()
4463 .and_then(|memory| memory.background_model.as_ref())
4464 .map(|value| value.trim())
4465 .filter(|value| !value.is_empty())
4466 .map(ToString::to_string);
4467 configured.or_else(|| self.get_fast_model())
4468 }
4469
4470 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4478 let raw = self
4479 .default_work_area
4480 .as_ref()
4481 .and_then(|config| config.path.as_ref())
4482 .map(|value| value.trim())
4483 .filter(|value| !value.is_empty())?;
4484
4485 let candidate = expand_user_path(raw);
4486 if candidate.is_absolute() {
4487 let canonical = std::fs::canonicalize(&candidate).ok();
4488 return canonical
4489 .as_ref()
4490 .filter(|path| path.is_dir())
4491 .map(|_| candidate.clone())
4492 .or_else(|| candidate.is_dir().then_some(candidate));
4493 }
4494
4495 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4496 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4497 canonical
4498 .as_ref()
4499 .filter(|path| path.is_dir())
4500 .map(|_| from_bamboo_dir.clone())
4501 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4502 .or_else(|| candidate.is_dir().then_some(candidate))
4503 }
4504
4505 pub fn get_vision_model(&self) -> Option<String> {
4511 if self.features.provider_model_ref {
4512 if let Some(model_ref) = self
4513 .defaults
4514 .as_ref()
4515 .and_then(|defaults| defaults.vision.as_ref())
4516 .or_else(|| {
4517 self.defaults
4518 .as_ref()
4519 .and_then(|defaults| defaults.fast.as_ref())
4520 })
4521 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4522 {
4523 return Some(model_ref.model.clone());
4524 }
4525 }
4526 let provider = self.effective_default_provider();
4527 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4528 instance.vision_model.clone()
4529 } else {
4530 match provider {
4531 "openai" => self
4532 .providers
4533 .openai
4534 .as_ref()
4535 .and_then(|c| c.vision_model.clone()),
4536 "anthropic" => self
4537 .providers
4538 .anthropic
4539 .as_ref()
4540 .and_then(|c| c.vision_model.clone()),
4541 "gemini" => self
4542 .providers
4543 .gemini
4544 .as_ref()
4545 .and_then(|c| c.vision_model.clone()),
4546 "copilot" => self
4547 .providers
4548 .copilot
4549 .as_ref()
4550 .and_then(|c| c.vision_model.clone()),
4551 _ => None,
4552 }
4553 };
4554 vision.or_else(|| self.get_fast_model())
4555 }
4556
4557 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4561 if self.features.provider_model_ref {
4562 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4563 return chat
4564 .reasoning_effort
4565 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4566 }
4567 }
4568 self.reasoning_effort_for_key(self.effective_default_provider())
4569 }
4570
4571 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4581 let trimmed = key.trim();
4582 if trimmed.is_empty() {
4583 return None;
4584 }
4585
4586 if let Some(instance) = self.provider_instances.get(trimmed) {
4588 return instance.reasoning_effort;
4589 }
4590
4591 match trimmed {
4593 "openai" => self
4594 .providers
4595 .openai
4596 .as_ref()
4597 .and_then(|c| c.reasoning_effort),
4598 "anthropic" => self
4599 .providers
4600 .anthropic
4601 .as_ref()
4602 .and_then(|c| c.reasoning_effort),
4603 "gemini" => self
4604 .providers
4605 .gemini
4606 .as_ref()
4607 .and_then(|c| c.reasoning_effort),
4608 "copilot" => self
4609 .providers
4610 .copilot
4611 .as_ref()
4612 .and_then(|c| c.reasoning_effort),
4613 "bodhi" => self
4614 .providers
4615 .bodhi
4616 .as_ref()
4617 .and_then(|c| c.reasoning_effort),
4618 _ => None,
4619 }
4620 }
4621
4622 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4629 self.tools
4630 .disabled
4631 .iter()
4632 .map(|name| name.trim())
4633 .filter(|name| !name.is_empty())
4634 .map(str::to_string)
4635 .collect()
4636 }
4637
4638 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4643 self.disabled_tool_references()
4644 .into_iter()
4645 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4646 .collect()
4647 }
4648
4649 pub fn normalize_tool_settings(&mut self) {
4651 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4652 }
4653
4654 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4656 self.skills
4657 .disabled
4658 .iter()
4659 .map(|id| id.trim())
4660 .filter(|id| !id.is_empty())
4661 .map(|id| id.to_string())
4662 .collect()
4663 }
4664
4665 pub fn normalize_skill_settings(&mut self) {
4667 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4668 }
4669
4670 pub fn normalize_plugin_trust_settings(&mut self) {
4678 self.plugin_trust.trusted_hosts = self
4679 .plugin_trust
4680 .trusted_hosts
4681 .iter()
4682 .map(|entry| entry.trim().to_ascii_lowercase())
4683 .filter(|entry| !entry.is_empty())
4684 .collect();
4685 }
4686
4687 pub fn effective_default_provider(&self) -> &str {
4692 self.default_provider_instance
4693 .as_deref()
4694 .unwrap_or(&self.provider)
4695 }
4696
4697 pub fn has_provider_instances(&self) -> bool {
4699 !self.provider_instances.is_empty()
4700 }
4701
4702 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4704 self.env_vars
4705 .iter()
4706 .filter(|e| !e.value.trim().is_empty())
4707 .map(|e| (e.name.clone(), e.value.clone()))
4708 .collect()
4709 }
4710
4711 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4712 self.env_vars
4713 .iter()
4714 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4715 .map(|entry| PromptSafeEnvVarEntry {
4716 name: entry.name.clone(),
4717 secret: entry.secret,
4718 description: entry
4719 .description
4720 .as_ref()
4721 .map(|value| value.trim().to_string())
4722 .filter(|value| !value.is_empty()),
4723 })
4724 .collect()
4725 }
4726
4727 pub fn publish_env_vars(&self) {
4729 let map = self.env_vars_as_map();
4730
4731 #[cfg(any(test, feature = "test-utils"))]
4732 if crate::test_support::env_vars_cache_override_is_active() {
4733 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4734 return;
4735 }
4736
4737 let mut env_guard = env_vars_cache().write().recover_poison();
4738 *env_guard = map;
4739
4740 let prompt_safe = self.prompt_safe_env_vars();
4741 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4742 *prompt_guard = prompt_safe;
4743 }
4744
4745 pub fn current_env_vars() -> HashMap<String, String> {
4747 #[cfg(any(test, feature = "test-utils"))]
4748 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4749 return env_vars;
4750 }
4751
4752 env_vars_cache().read().recover_poison().clone()
4753 }
4754
4755 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4757 #[cfg(any(test, feature = "test-utils"))]
4758 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4759 return env_vars;
4760 }
4761
4762 prompt_safe_env_vars_cache().read().recover_poison().clone()
4763 }
4764
4765 fn create_default() -> Self {
4767 Self::from_parts(
4768 ConfigValues {
4769 http_proxy: String::new(),
4770 https_proxy: String::new(),
4771 proxy_auth: None,
4772 proxy_auth_encrypted: None,
4773 proxy_auth_credential_ref: None,
4774 headless_auth: false,
4775 run_budget: RunBudgetConfig::default(),
4776 stream_timeout: StreamTimeoutConfig::default(),
4777 context_management: ContextManagementConfig::default(),
4778 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4779 provider: default_provider(),
4780 provider_instances: HashMap::new(),
4781 default_provider_instance: None,
4782 server: ServerConfig::default(),
4783 keyword_masking: KeywordMaskingConfig::default(),
4784 anthropic_model_mapping: AnthropicModelMapping::default(),
4785 gemini_model_mapping: GeminiModelMapping::default(),
4786 hooks: HooksConfig::default(),
4787 lifecycle_hooks: LifecycleHooksConfig::default(),
4788 tools: ToolsConfig::default(),
4789 skills: SkillsConfig::default(),
4790 env_vars: Vec::new(),
4791 default_work_area: None,
4792 access_control: None,
4793 features: FeatureFlags::default(),
4794 defaults: None,
4795 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4796 notifications: NotificationsConfig::default(),
4797 connect: ConnectConfig::default(),
4798 plugin_trust: PluginTrustConfig::default(),
4799 extra: BTreeMap::new(),
4800 },
4801 None,
4802 SubagentsConfig::default(),
4803 ProviderConfigs::default(),
4804 )
4805 }
4806
4807 pub fn server_addr(&self) -> String {
4809 format!("{}:{}", self.server.bind, self.server.port)
4810 }
4811
4812 pub fn save(&self) -> Result<()> {
4814 self.save_to_dir(default_data_dir())
4815 }
4816
4817 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4820 self.memory.save_sync(data_dir)
4821 }
4822
4823 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4826 self.subagents.save_sync(data_dir)
4827 }
4828
4829 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4833 crate::config_module::ensure_legacy_module_layout(data_dir)?;
4834 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4835 let mut config = self.clone();
4836 config.clear_legacy_provider_aliases_for_instance_mode();
4837 config.refresh_provider_api_keys_encrypted()?;
4838 config.providers.save_sync(data_dir)
4839 }
4840
4841 pub(crate) fn prepare_provider_transaction_documents(
4845 &self,
4846 provider_document: &[u8],
4847 ) -> Result<(Vec<u8>, Vec<u8>)> {
4848 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4849 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4850 anyhow::bail!(
4851 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4852 status.source
4853 );
4854 }
4855
4856 let mut to_save = self.clone();
4857 to_save.clear_legacy_provider_aliases_for_instance_mode();
4858 to_save.extra.remove("data_dir");
4859 to_save.extra.remove("model");
4860 to_save.refresh_encrypted_secrets()?;
4861 to_save.ensure_provider_instance_credentials_isolated()?;
4862 to_save.sanitize_mcp_credential_refs_for_disk();
4863 to_save.sanitize_env_vars_for_disk();
4864 to_save.sanitize_notifications_for_disk();
4865 to_save.sanitize_cluster_fabric_for_disk();
4866 to_save.assign_connect_platform_ids();
4867 to_save.normalize_tool_settings();
4868 to_save.normalize_skill_settings();
4869
4870 let mut root = durable_root_value(to_save.values.clone())
4871 .context("Failed to serialize root config DTO to JSON")?;
4872 if let Some(object) = root.as_object_mut() {
4873 object.remove("connect");
4874 }
4875 let root = serde_json::to_vec_pretty(&root)?;
4876
4877 let mut providers = to_save.providers.0.clone();
4878 macro_rules! sanitize_provider {
4879 ($field:ident) => {
4880 if let Some(provider) = providers.$field.as_mut() {
4881 if !provider.api_key.trim().is_empty()
4882 && !provider.api_key_from_env
4883 && provider.credential_ref.is_none()
4884 {
4885 anyhow::bail!(
4886 "provider secret requires credential transaction before persistence"
4887 );
4888 }
4889 provider.api_key_encrypted = None;
4890 }
4891 };
4892 }
4893 sanitize_provider!(openai);
4894 sanitize_provider!(anthropic);
4895 sanitize_provider!(gemini);
4896 if let Some(provider) = providers.bodhi.as_mut() {
4897 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4898 anyhow::bail!("provider secret requires credential transaction before persistence");
4899 }
4900 provider.api_key_encrypted = None;
4901 }
4902
4903 let existing_provider_value = if provider_document.is_empty() {
4904 None
4905 } else {
4906 Some(
4907 serde_json::from_slice::<Value>(provider_document)
4908 .context("provider metadata document is invalid")?,
4909 )
4910 };
4911 let provider_value = match existing_provider_value {
4912 Some(Value::Object(mut envelope))
4913 if envelope.contains_key("schema_version")
4914 || envelope.contains_key("revision")
4915 || envelope.contains_key("data") =>
4916 {
4917 let revision = envelope
4918 .get("revision")
4919 .and_then(Value::as_u64)
4920 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4921 let schema_version = envelope
4922 .get("schema_version")
4923 .and_then(Value::as_u64)
4924 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4925 if schema_version != 1 || !envelope.contains_key("data") {
4926 anyhow::bail!("provider revision envelope is unsupported");
4927 }
4928 let revision = revision
4929 .checked_add(1)
4930 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4931 envelope.insert("revision".into(), Value::from(revision));
4932 envelope.insert("data".into(), serde_json::to_value(providers)?);
4933 Value::Object(envelope)
4934 }
4935 Some(_) | None => serde_json::to_value(providers)?,
4936 };
4937 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4938 }
4939
4940 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4944 self.recovery_status.as_ref()
4945 }
4946
4947 pub fn confirm_recovery(&mut self) {
4953 if let Some(status) = self.recovery_status.as_mut() {
4954 status.confirmed = true;
4955 }
4956 }
4957
4958 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4966 if self.recovery_status.is_none() {
4967 anyhow::bail!("No pending config-corruption recovery to confirm");
4968 }
4969 self.confirm_recovery();
4970 self.save_to_dir(data_dir)?;
4971 self.recovery_status = None;
4972 Ok(())
4973 }
4974
4975 pub fn assign_connect_platform_ids(&mut self) {
4991 for platform in &mut self.connect.platforms {
4992 if platform.id.is_none() {
4993 platform.id = Some(uuid::Uuid::new_v4().to_string());
4994 }
4995 }
4996 }
4997
4998 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5010 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5011 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5012 anyhow::bail!(
5013 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5014 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5015 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5016 status.source,
5017 status.quarantine_path,
5018 );
5019 }
5020 if self.proxy_auth_credential_ref.is_none()
5021 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5022 {
5023 anyhow::bail!(
5024 "proxy auth requires the isolated credential transaction before persistence"
5025 );
5026 }
5027 if self.env_vars.iter().any(|entry| {
5028 entry.secret
5029 && entry.credential_ref.is_none()
5030 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5031 }) {
5032 anyhow::bail!(
5033 "secret env vars require the isolated credential transaction before persistence"
5034 );
5035 }
5036 if [
5037 (
5038 &self.notifications.ntfy.token,
5039 &self.notifications.ntfy.token_encrypted,
5040 &self.notifications.ntfy.credential_ref,
5041 ),
5042 (
5043 &self.notifications.bark.device_key,
5044 &self.notifications.bark.device_key_encrypted,
5045 &self.notifications.bark.credential_ref,
5046 ),
5047 ]
5048 .iter()
5049 .any(|(plaintext, ciphertext, reference)| {
5050 reference.is_none()
5051 && (plaintext
5052 .as_deref()
5053 .is_some_and(|value| !value.trim().is_empty())
5054 || ciphertext.is_some())
5055 }) {
5056 anyhow::bail!(
5057 "notification secrets require the isolated credential transaction before persistence"
5058 );
5059 }
5060
5061 if crate::modular_authority_boundary_present(&data_dir)
5062 .context("Failed to inspect modular configuration authority boundary")?
5063 {
5064 crate::ConfigFacade::open_or_migrate(&data_dir)
5065 .context("Failed to recover modular configuration before persistence")?;
5066 crate::persist_facade_effective_config(&data_dir, self)
5067 .context("Failed to persist modular configuration sections")?;
5068 return Ok(());
5069 }
5070
5071 if crate::section_layout_is_active(&data_dir)
5072 .context("Failed to inspect modular configuration layout")?
5073 {
5074 crate::persist_facade_effective_config(&data_dir, self)
5075 .context("Failed to persist modular configuration sections")?;
5076 return Ok(());
5077 }
5078
5079 let path = data_dir.join("config.json");
5080
5081 if let Some(parent) = path.parent() {
5082 std::fs::create_dir_all(parent)
5083 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5084 }
5085
5086 let mut to_save = self.clone();
5087 to_save.clear_legacy_provider_aliases_for_instance_mode();
5088 to_save.extra.remove("data_dir");
5090 to_save.extra.remove("model");
5092 to_save.refresh_encrypted_secrets()?;
5095 to_save.ensure_provider_instance_credentials_isolated()?;
5096 to_save.sanitize_mcp_credential_refs_for_disk();
5097 to_save.sanitize_env_vars_for_disk();
5098 to_save.sanitize_notifications_for_disk();
5099 to_save.sanitize_cluster_fabric_for_disk();
5100 to_save.assign_connect_platform_ids();
5101 to_save.normalize_tool_settings();
5102 to_save.normalize_skill_settings();
5103
5104 let mut config_value = durable_root_value(to_save.values.clone())
5114 .context("Failed to serialize root config DTO to JSON")?;
5115 if let Some(obj) = config_value.as_object_mut() {
5116 obj.remove("connect");
5117 }
5118 let content = serde_json::to_string_pretty(&config_value)
5119 .context("Failed to serialize config to JSON")?;
5120
5121 to_save.memory.save_sync(&data_dir)?;
5128 to_save.subagents.save_sync(&data_dir)?;
5129 to_save.providers.save_sync(&data_dir)?;
5130
5131 if path.exists()
5138 && std::fs::read_to_string(&path)
5139 .ok()
5140 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5141 {
5142 rotate_backups(&path, BAK_GENERATIONS);
5146 let backup = backup_path_for(&path, 0);
5147 let backup_result = std::fs::read(&path).and_then(|bytes| {
5148 let mut value: Value = serde_json::from_slice(&bytes)
5149 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5150 if sanitize_ref_backed_mcp_json(&mut value) {
5151 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5152 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5153 })?;
5154 write_atomic(&backup, &sanitized)
5155 } else {
5156 std::fs::copy(&path, &backup).map(|_| ())
5157 }
5158 });
5159 if let Err(e) = backup_result {
5160 tracing::warn!("Failed to back up config.json before save: {}", e);
5161 }
5162 scrub_ref_backed_mcp_from_config_backups(&path);
5163 }
5164
5165 write_atomic(&path, content.as_bytes())
5166 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5167
5168 save_connect_config(&to_save.connect, &data_dir)?;
5169
5170 Ok(())
5171 }
5172}
5173
5174fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5179 let Some(object) = root.as_object_mut() else {
5180 return false;
5181 };
5182 let Some(mcp) = (if object.contains_key("mcpServers") {
5183 object.get_mut("mcpServers")
5184 } else {
5185 object.get_mut("mcp")
5186 }) else {
5187 return false;
5188 };
5189 let mut changed = false;
5190 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5191 for server in servers {
5192 if let Some(object) = server.as_object_mut() {
5193 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5194 {
5195 changed |= sanitize_ref_backed_mcp_transport(transport);
5196 }
5197 }
5198 }
5199 } else if let Some(servers) = mcp.as_object_mut() {
5200 for server in servers.values_mut() {
5201 if let Some(object) = server.as_object_mut() {
5202 changed |= sanitize_ref_backed_mcp_transport(object);
5203 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5204 {
5205 changed |= sanitize_ref_backed_mcp_transport(transport);
5206 }
5207 }
5208 }
5209 }
5210 changed
5211}
5212
5213fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5214 let mut changed = false;
5215 let env_names = object
5216 .get("env_credential_refs")
5217 .and_then(Value::as_object)
5218 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5219 .unwrap_or_default();
5220 for name in env_names {
5221 for field in ["env", "env_encrypted"] {
5222 if object
5223 .get_mut(field)
5224 .and_then(Value::as_object_mut)
5225 .and_then(|values| values.remove(&name))
5226 .is_some()
5227 {
5228 changed = true;
5229 }
5230 }
5231 }
5232 let header_names = object
5233 .get("header_credential_refs")
5234 .and_then(Value::as_object)
5235 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5236 .unwrap_or_default();
5237 for name in header_names {
5238 for field in ["headers", "headers_encrypted"] {
5239 if object
5240 .get_mut(field)
5241 .and_then(Value::as_object_mut)
5242 .and_then(|values| values.remove(&name))
5243 .is_some()
5244 {
5245 changed = true;
5246 }
5247 }
5248 }
5249 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5250 for header in headers {
5251 let Some(header) = header.as_object_mut() else {
5252 continue;
5253 };
5254 if header.get("credential_ref").is_some_and(Value::is_string) {
5255 changed |= header.remove("value").is_some();
5256 changed |= header.remove("value_encrypted").is_some();
5257 }
5258 }
5259 }
5260 changed
5261}
5262
5263fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5264 for generation in 0..BAK_GENERATIONS {
5265 let backup = backup_path_for(path, generation);
5266 let Ok(bytes) = std::fs::read(&backup) else {
5267 continue;
5268 };
5269 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5270 continue;
5271 };
5272 if sanitize_ref_backed_mcp_json(&mut value) {
5273 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5274 if let Err(error) = write_atomic(&backup, &sanitized) {
5275 tracing::warn!(
5276 "Failed to scrub MCP credentials from {:?}: {}",
5277 backup,
5278 error
5279 );
5280 }
5281 }
5282 }
5283 }
5284}
5285
5286fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5296 let path = data_dir.join("connect.json");
5297 if connect_config_is_empty(connect) && !path.exists() {
5298 return Ok(());
5299 }
5300
5301 if path.exists() {
5302 let backup = path.with_extension("json.bak");
5303 if let Err(e) = std::fs::copy(&path, &backup) {
5304 tracing::warn!("Failed to back up connect.json before save: {}", e);
5305 }
5306 }
5307
5308 let content = serde_json::to_string_pretty(connect)
5309 .context("Failed to serialize connect config to JSON")?;
5310 write_atomic(&path, content.as_bytes())
5311 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5312 Ok(())
5313}
5314
5315fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5333 let config_path = data_dir.join("config.json");
5334 let content = match std::fs::read_to_string(&config_path) {
5335 Ok(content) => content,
5336 Err(e) => {
5337 tracing::error!(
5338 "Failed to read config.json to strip legacy `connect` key: {}",
5339 e
5340 );
5341 return;
5342 }
5343 };
5344 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5345 Ok(value) => value,
5346 Err(e) => {
5347 tracing::error!(
5348 "Failed to parse config.json to strip legacy `connect` key: {}",
5349 e
5350 );
5351 return;
5352 }
5353 };
5354 let Some(obj) = value.as_object_mut() else {
5355 return;
5356 };
5357 if obj.remove("connect").is_none() {
5358 return;
5361 }
5362 let rewritten = match serde_json::to_string_pretty(&value) {
5363 Ok(rewritten) => rewritten,
5364 Err(e) => {
5365 tracing::error!(
5366 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5367 e
5368 );
5369 return;
5370 }
5371 };
5372 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5373 tracing::error!(
5374 "Failed to write config.json after stripping legacy `connect` key: {}",
5375 e
5376 );
5377 }
5378}
5379
5380fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5417 let config_path = data_dir.join("config.json");
5418 for gen in 0..BAK_GENERATIONS {
5419 let backup = backup_path_for(&config_path, gen);
5420 let content = match std::fs::read_to_string(&backup) {
5421 Ok(content) => content,
5422 Err(e) => {
5423 if e.kind() != std::io::ErrorKind::NotFound {
5424 tracing::warn!(
5425 "Failed to read {:?} while scanning for legacy connect data ({}); \
5426 leaving it untouched",
5427 backup,
5428 e
5429 );
5430 }
5431 continue;
5432 }
5433 };
5434 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5435 Ok(value) => value,
5436 Err(e) => {
5437 tracing::warn!(
5438 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5439 ({}); left untouched (never deleted)",
5440 backup,
5441 e
5442 );
5443 continue;
5444 }
5445 };
5446 let Some(obj) = value.as_object_mut() else {
5447 continue;
5450 };
5451 if obj.remove("connect").is_none() {
5452 continue;
5455 }
5456 let rewritten = match serde_json::to_string_pretty(&value) {
5457 Ok(rewritten) => rewritten,
5458 Err(e) => {
5459 tracing::error!(
5460 "Failed to serialize {:?} after stripping legacy connect data: {}",
5461 backup,
5462 e
5463 );
5464 continue;
5465 }
5466 };
5467 match write_atomic(&backup, rewritten.as_bytes()) {
5468 Ok(()) => tracing::info!(
5469 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5470 backup
5471 ),
5472 Err(e) => tracing::error!(
5473 "Failed to write {:?} after stripping legacy connect data: {}",
5474 backup,
5475 e
5476 ),
5477 }
5478 }
5479}
5480
5481fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5496 let backup = connect_path.with_extension("json.bak");
5497 match std::fs::rename(connect_path, &backup) {
5498 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5499 Err(e) => {
5500 tracing::warn!(
5501 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5502 backup,
5503 e
5504 );
5505 if let Err(e) = std::fs::copy(connect_path, &backup) {
5506 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5507 return;
5508 }
5509 if let Err(e) = std::fs::remove_file(connect_path) {
5510 tracing::error!(
5511 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5512 original {:?}: {}",
5513 backup,
5514 connect_path,
5515 e
5516 );
5517 }
5518 }
5519 }
5520}
5521
5522const QUARANTINE_KEEP: usize = 5;
5525
5526fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5535 let nanos = std::time::SystemTime::now()
5536 .duration_since(std::time::UNIX_EPOCH)
5537 .map(|d| d.as_nanos())
5538 .unwrap_or(0);
5539 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5543 let mut dedup = 1u32;
5544 while quarantine.exists() {
5545 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5546 dedup += 1;
5547 }
5548 let result = match std::fs::copy(config_path, &quarantine) {
5549 Ok(_) => {
5550 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5551 Some(quarantine)
5552 }
5553 Err(e) => {
5554 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5555 None
5556 }
5557 };
5558 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5559 result
5560}
5561
5562fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5565 let Some(dir) = config_path.parent() else {
5566 return;
5567 };
5568 let prefix = "config.json.corrupted.";
5569 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5570 Ok(entries) => entries
5571 .filter_map(|e| e.ok())
5572 .map(|e| e.path())
5573 .filter(|p| {
5574 p.file_name()
5575 .and_then(|n| n.to_str())
5576 .is_some_and(|n| n.starts_with(prefix))
5577 })
5578 .collect(),
5579 Err(_) => return,
5580 };
5581 if quarantines.len() <= keep {
5582 return;
5583 }
5584 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5586 let remove = quarantines.len() - keep;
5587 for stale in quarantines.into_iter().take(remove) {
5588 if let Err(e) = std::fs::remove_file(&stale) {
5589 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5590 }
5591 }
5592}
5593
5594const BAK_GENERATIONS: usize = 3;
5597
5598fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5600 if gen == 0 {
5601 config_path.with_extension("json.bak")
5602 } else {
5603 config_path.with_extension(format!("json.bak.{gen}"))
5604 }
5605}
5606
5607fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5613 for gen in (1..generations).rev() {
5614 let from = backup_path_for(config_path, gen - 1);
5615 let to = backup_path_for(config_path, gen);
5616 if from.exists() {
5617 if let Err(e) = std::fs::rename(&from, &to) {
5618 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5619 }
5620 }
5621 }
5622}
5623
5624pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5625 crate::config_store::AtomicFileStore::new(path)
5626 .write_bytes_without_backup(content)
5627 .map_err(|error| match error {
5628 crate::config_store::ConfigStoreError::Io(error) => error,
5629 other => std::io::Error::other(other),
5630 })
5631}
5632
5633#[cfg(test)]
5634mod tests {
5635 use super::*;
5636 use std::ffi::OsString;
5637 use std::path::PathBuf;
5638 use std::sync::Mutex;
5639 use std::time::{SystemTime, UNIX_EPOCH};
5640
5641 #[test]
5642 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5643 let input = serde_json::json!({
5644 "disabled": ["bash"],
5645 "plugin_runtime": {
5646 "timeout_ms": 5_000,
5647 "sandbox": true
5648 },
5649 "future_flag": "enabled"
5650 });
5651
5652 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5653 assert_eq!(config.disabled, vec!["bash"]);
5654 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5655 assert_eq!(config.extra["future_flag"], "enabled");
5656 assert_eq!(serde_json::to_value(config).unwrap(), input);
5657 }
5658
5659 #[test]
5660 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5661 let input = serde_json::json!({
5662 "tool_extension": {
5663 "mode": "strict",
5664 "options": ["one", "two"]
5665 }
5666 });
5667 let config: Config = serde_json::from_value(serde_json::json!({
5668 "tools": input.clone()
5669 }))
5670 .unwrap();
5671
5672 assert!(config.tools.disabled.is_empty());
5673 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5674 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5675
5676 let temp_home = TempHome::new();
5677 config.save_to_dir(temp_home.path.clone()).unwrap();
5678 let persisted: Value =
5679 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5680 .unwrap();
5681 assert_eq!(persisted["tools"], input);
5682 }
5683
5684 #[test]
5685 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5686 let input = serde_json::json!({
5687 "external_catalog": {
5688 "path": "/opt/bamboo/skills",
5689 "refresh": false
5690 },
5691 "schema_version": 2
5692 });
5693
5694 let config: Config = serde_json::from_value(serde_json::json!({
5695 "skills": input.clone()
5696 }))
5697 .unwrap();
5698 assert!(config.skills.disabled.is_empty());
5699 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5700 assert_eq!(config.skills.extra["schema_version"], 2);
5701 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5702
5703 let temp_home = TempHome::new();
5704 config.save_to_dir(temp_home.path.clone()).unwrap();
5705 let persisted: Value =
5706 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5707 .unwrap();
5708 assert_eq!(persisted["skills"], input);
5709 }
5710
5711 #[test]
5712 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5713 let config: Config = serde_json::from_value(serde_json::json!({
5714 "hooks": {
5715 "image_fallback": {"enabled": true, "mode": "placeholder"}
5716 },
5717 "lifecycle_hooks": {
5718 "enabled": true,
5719 "PreToolUse": [{
5720 "matcher": "bash|write_file",
5721 "hooks": [{"type": "command", "command": "guard.sh"}]
5722 }],
5723 "SessionStart": [{
5724 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5725 }]
5726 }
5727 }))
5728 .expect("lifecycle hook config should deserialize");
5729
5730 assert!(config.lifecycle_hooks.enabled);
5731 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5732 assert!(
5733 config.lifecycle_hooks.pre_tool_use[0].enabled,
5734 "legacy groups without an enabled flag remain active"
5735 );
5736 assert_eq!(
5737 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5738 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5739 );
5740 assert_eq!(
5741 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5742 25
5743 );
5744
5745 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5746 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5747 assert_eq!(
5748 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5749 "bash|write_file"
5750 );
5751 assert_eq!(
5752 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5753 "command"
5754 );
5755 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5756 .get("enabled")
5757 .is_none());
5758 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5759 .get("timeout_ms")
5760 .is_none());
5761 assert!(json.get("hooks").is_some());
5762 }
5763
5764 #[test]
5765 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5766 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5767 "type": "script",
5768 "path": ".bamboo/hooks/check.js"
5769 }))
5770 .expect("script lifecycle hook should deserialize");
5771
5772 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5773 assert!(matches!(
5774 handler,
5775 LifecycleHookHandler::Script {
5776 runner: LifecycleScriptRunner::Auto,
5777 ..
5778 }
5779 ));
5780 let json = serde_json::to_value(handler).expect("script hook should serialize");
5781 assert_eq!(json["type"], "script");
5782 assert!(json.get("timeout_ms").is_none());
5783 assert!(json.get("runner").is_none());
5784 }
5785
5786 #[test]
5787 fn script_runner_support_is_extension_aware() {
5788 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5789 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5790 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5791 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5792 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5793 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5794 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5795 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5796 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5797 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5798 }
5799
5800 #[test]
5801 fn script_runner_names_round_trip_through_config_json() {
5802 for (runner, name) in [
5803 (LifecycleScriptRunner::Auto, "auto"),
5804 (LifecycleScriptRunner::Node, "node"),
5805 (LifecycleScriptRunner::Bun, "bun"),
5806 (LifecycleScriptRunner::Python, "python"),
5807 (LifecycleScriptRunner::Bash, "bash"),
5808 (LifecycleScriptRunner::PowerShell, "powershell"),
5809 (LifecycleScriptRunner::Cmd, "cmd"),
5810 ] {
5811 let json = serde_json::to_value(runner).unwrap();
5812 assert_eq!(json, name);
5813 assert_eq!(
5814 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5815 runner
5816 );
5817 }
5818 }
5819
5820 #[test]
5821 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5822 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5823 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5824 let json = serde_json::to_value(&config).unwrap();
5825 assert!(json.get("lifecycle_hooks").is_none());
5826 }
5827
5828 #[test]
5829 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5830 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5831 .expect("legacy config without stream_timeout should load");
5832 let values = ConfigValues::from(root);
5833
5834 assert_eq!(
5835 values.stream_timeout,
5836 StreamTimeoutConfig {
5837 transport_idle_timeout_secs: 120,
5838 first_semantic_timeout_secs: 600,
5839 semantic_idle_timeout_secs: 600,
5840 }
5841 );
5842 values
5843 .stream_timeout
5844 .validate()
5845 .expect("defaults are valid");
5846 }
5847
5848 #[test]
5849 fn stream_timeout_round_trips_through_persistence_dto() {
5850 let values = ConfigValues {
5851 stream_timeout: StreamTimeoutConfig {
5852 transport_idle_timeout_secs: 45,
5853 first_semantic_timeout_secs: 900,
5854 semantic_idle_timeout_secs: 300,
5855 },
5856 ..ConfigValues::default()
5857 };
5858
5859 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5860 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5861 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5862 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5863
5864 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5865 assert_eq!(
5866 ConfigValues::from(root).stream_timeout,
5867 StreamTimeoutConfig {
5868 transport_idle_timeout_secs: 45,
5869 first_semantic_timeout_secs: 900,
5870 semantic_idle_timeout_secs: 300,
5871 }
5872 );
5873 }
5874
5875 #[test]
5876 fn context_management_defaults_to_summary_and_is_omitted() {
5877 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5878 assert_eq!(
5879 config.context_management.strategy,
5880 ContextManagementStrategy::Summary
5881 );
5882 assert_eq!(
5883 config
5884 .context_management
5885 .retrieval_window
5886 .min_recent_user_turns,
5887 3
5888 );
5889 assert_eq!(
5890 config.context_management.retrieval_target_usage_percent(),
5891 60
5892 );
5893 let json = serde_json::to_value(&config).unwrap();
5894 assert!(json.get("context_management").is_none());
5895 }
5896
5897 #[test]
5898 fn retrieval_window_context_management_round_trips() {
5899 let json = serde_json::json!({
5900 "context_management": {
5901 "strategy": "retrieval_window",
5902 "retrieval_window": {
5903 "min_recent_user_turns": 4,
5904 "trigger_usage_ratio": 0.82,
5905 "target_usage_ratio": 0.61,
5906 "history_tool_required": true,
5907 "fallback_strategy": "summary"
5908 }
5909 }
5910 });
5911 let root: ConfigRoot = serde_json::from_value(json).unwrap();
5912 let values = ConfigValues::from(root);
5913 assert_eq!(
5914 values.context_management.strategy,
5915 ContextManagementStrategy::RetrievalWindow
5916 );
5917 assert_eq!(
5918 values.context_management.retrieval_window.fallback_strategy,
5919 ContextManagementFallbackStrategy::Summary
5920 );
5921
5922 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5923 assert_eq!(
5924 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
5925 4
5926 );
5927 assert_eq!(
5928 persisted["context_management"]["strategy"],
5929 "retrieval_window"
5930 );
5931 }
5932
5933 #[test]
5934 fn message_summary_context_management_round_trips_without_changing_defaults() {
5935 let root: ConfigRoot = serde_json::from_value(serde_json::json!({
5936 "context_management": {
5937 "strategy": "message_summary"
5938 }
5939 }))
5940 .unwrap();
5941 let values = ConfigValues::from(root);
5942 assert_eq!(
5943 values.context_management.strategy,
5944 ContextManagementStrategy::MessageSummary
5945 );
5946 assert_eq!(
5947 values.context_management.retrieval_window,
5948 RetrievalWindowContextConfig::default()
5949 );
5950
5951 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5952 assert_eq!(
5953 persisted["context_management"]["strategy"],
5954 "message_summary"
5955 );
5956 let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
5957 assert_eq!(
5958 ConfigValues::from(reloaded).context_management.strategy,
5959 ContextManagementStrategy::MessageSummary
5960 );
5961 assert_eq!(
5962 ContextManagementConfig::default().strategy,
5963 ContextManagementStrategy::Summary
5964 );
5965 }
5966
5967 #[test]
5968 fn retrieval_window_context_management_rejects_unsafe_policy() {
5969 for invalid in [
5970 serde_json::json!({
5971 "strategy": "retrieval_window",
5972 "retrieval_window": {"min_recent_user_turns": 0}
5973 }),
5974 serde_json::json!({
5975 "strategy": "retrieval_window",
5976 "retrieval_window": {
5977 "target_usage_ratio": 0.8,
5978 "trigger_usage_ratio": 0.8
5979 }
5980 }),
5981 serde_json::json!({
5982 "strategy": "retrieval_window",
5983 "retrieval_window": {
5984 "target_usage_ratio": 0.005,
5985 "trigger_usage_ratio": 0.006
5986 }
5987 }),
5988 serde_json::json!({
5989 "strategy": "retrieval_window",
5990 "retrieval_window": {"history_tool_required": false}
5991 }),
5992 ] {
5993 serde_json::from_value::<ContextManagementConfig>(invalid)
5994 .expect_err("unsafe retrieval-window policy must fail closed");
5995 }
5996 }
5997
5998 #[test]
5999 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6000 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6001 "provider_type": "openai",
6002 "enabled": true
6003 }))
6004 .unwrap();
6005 let mut config = Config::default();
6006 config.values.provider = "gemini".to_string();
6007 config
6008 .provider_instances
6009 .insert("work".to_string(), instance);
6010 config.default_provider_instance = Some("work".to_string());
6011 config.providers_mut().openai = Some(OpenAIConfig::default());
6012
6013 let json = serde_json::to_value(&config).unwrap();
6014 assert_eq!(json["provider"], "gemini");
6015 assert!(json["providers"]["openai"].is_object());
6016 assert_eq!(json["default_provider_instance"], "work");
6017
6018 let round_trip: Config = serde_json::from_value(json).unwrap();
6019 assert_eq!(round_trip.provider, "gemini");
6020 assert_eq!(
6021 round_trip.default_provider_instance.as_deref(),
6022 Some("work")
6023 );
6024 assert!(round_trip.provider_instances.contains_key("work"));
6025 assert!(round_trip.providers().openai.is_some());
6026 }
6027
6028 #[test]
6029 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6030 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6031 let dir = tempfile::tempdir().unwrap();
6032 let mut config = Config::default();
6033 config.values.provider = "anthropic".to_string();
6034 config.provider_instances.insert(
6035 "work".to_string(),
6036 serde_json::from_value(serde_json::json!({
6037 "provider_type": "openai",
6038 "model": "gpt-instance",
6039 "enabled": true,
6040 "future_instance_metadata": "preserved"
6041 }))
6042 .unwrap(),
6043 );
6044 config.default_provider_instance = Some("work".to_string());
6045 config.features.provider_model_ref = true;
6046 config.providers_mut().openai = Some(OpenAIConfig::default());
6047 config.providers_mut().anthropic = Some(AnthropicConfig::default());
6048 config
6049 .providers_mut()
6050 .extra
6051 .insert("provider".to_string(), serde_json::json!("anthropic"));
6052 config.providers_mut().extra.insert(
6053 "future_provider".to_string(),
6054 serde_json::json!({"kept": true}),
6055 );
6056
6057 let (root_bytes, provider_bytes) =
6058 config.prepare_provider_transaction_documents(&[]).unwrap();
6059 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6060 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6061 assert!(root.get("provider").is_none());
6062 assert!(root.get("providers").is_none());
6063 assert_eq!(root["default_provider_instance"], "work");
6064 assert_eq!(
6065 root["provider_instances"]["work"]["future_instance_metadata"],
6066 "preserved"
6067 );
6068 assert!(providers.get("provider").is_none());
6069 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6070 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6071 }
6072 assert_eq!(providers["future_provider"]["kept"], true);
6073
6074 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6075 let saved_root: Value =
6076 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6077 .unwrap();
6078 let saved_providers: Value =
6079 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6080 .unwrap();
6081 assert!(saved_root.get("provider").is_none());
6082 assert!(saved_root.get("providers").is_none());
6083 assert_eq!(saved_root["default_provider_instance"], "work");
6084 assert!(saved_providers.get("openai").is_none());
6085 assert!(saved_providers.get("anthropic").is_none());
6086 assert!(saved_providers.get("provider").is_none());
6087 assert_eq!(saved_providers["future_provider"]["kept"], true);
6088
6089 config.providers_mut().gemini = Some(GeminiConfig::default());
6090 config.save_providers_to_dir(dir.path()).unwrap();
6091 let provider_only: Value =
6092 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6093 .unwrap();
6094 assert!(provider_only.get("gemini").is_none());
6095 assert!(provider_only.get("provider").is_none());
6096 assert_eq!(provider_only["future_provider"]["kept"], true);
6097 }
6098
6099 #[test]
6100 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6101 let mut config = Config::default();
6102 config.values.provider = "openai".to_string();
6103 config.providers_mut().openai = Some(OpenAIConfig {
6104 model: Some("gpt-legacy".to_string()),
6105 ..OpenAIConfig::default()
6106 });
6107 config.provider_instances.insert(
6108 "work".to_string(),
6109 serde_json::from_value(serde_json::json!({
6110 "provider_type": "copilot",
6111 "enabled": true
6112 }))
6113 .unwrap(),
6114 );
6115 config.default_provider_instance = Some("openai".to_string());
6116
6117 let (root_bytes, provider_bytes) =
6118 config.prepare_provider_transaction_documents(&[]).unwrap();
6119 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6120 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6121 assert_eq!(root["provider"], "openai");
6122 assert_eq!(root["default_provider_instance"], "openai");
6123 assert!(root["provider_instances"]["work"].is_object());
6124 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6125 }
6126
6127 #[test]
6128 fn persistence_keeps_legacy_provider_without_instance_default() {
6129 let values = ConfigValues {
6130 provider: "gemini".to_string(),
6131 ..ConfigValues::default()
6132 };
6133
6134 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6135 assert_eq!(json["provider"], "gemini");
6136 }
6137
6138 #[test]
6139 fn stream_timeout_rejects_zero_and_unbounded_values() {
6140 for (field, invalid) in [
6141 ("transport_idle_timeout_secs", 0),
6142 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6143 ("semantic_idle_timeout_secs", 0),
6144 ] {
6145 let mut timeout = serde_json::json!({});
6146 timeout[field] = serde_json::json!(invalid);
6147 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6148 .expect_err("invalid timeout must be rejected");
6149 assert!(error.to_string().contains("stream timeout must be between"));
6150 }
6151 }
6152
6153 struct EnvVarGuard {
6154 key: &'static str,
6155 previous: Option<OsString>,
6156 }
6157
6158 impl EnvVarGuard {
6159 fn set(key: &'static str, value: &str) -> Self {
6160 let previous = std::env::var_os(key);
6161 std::env::set_var(key, value);
6162 Self { key, previous }
6163 }
6164
6165 fn unset(key: &'static str) -> Self {
6166 let previous = std::env::var_os(key);
6167 std::env::remove_var(key);
6168 Self { key, previous }
6169 }
6170 }
6171
6172 impl Drop for EnvVarGuard {
6173 fn drop(&mut self) {
6174 match &self.previous {
6175 Some(value) => std::env::set_var(self.key, value),
6176 None => std::env::remove_var(self.key),
6177 }
6178 }
6179 }
6180
6181 #[test]
6182 fn run_budget_config_merge_is_tighten_only_per_field() {
6183 let config_default = RunBudgetConfig {
6184 max_total_tokens: Some(100_000),
6185 max_tool_calls: Some(500),
6186 max_subagents: Some(10),
6187 max_rounds: Some(200),
6188 };
6189
6190 assert_eq!(
6192 config_default.merged_with_override(None),
6193 config_default,
6194 "no override falls back to the config default entirely"
6195 );
6196
6197 let tighten_one = RunBudgetConfig {
6200 max_total_tokens: Some(5_000),
6201 max_tool_calls: None,
6202 max_subagents: None,
6203 max_rounds: None,
6204 };
6205 let merged = config_default.merged_with_override(Some(&tighten_one));
6206 assert_eq!(merged.max_total_tokens, Some(5_000));
6207 assert_eq!(merged.max_tool_calls, Some(500));
6208 assert_eq!(merged.max_subagents, Some(10));
6209 assert_eq!(merged.max_rounds, Some(200));
6210
6211 let loosen_attempt = RunBudgetConfig {
6214 max_total_tokens: Some(999_999_999),
6215 max_tool_calls: Some(10_000),
6216 max_subagents: Some(1_000),
6217 max_rounds: Some(100_000),
6218 };
6219 assert_eq!(
6220 config_default.merged_with_override(Some(&loosen_attempt)),
6221 config_default,
6222 "looser per-request values must be clamped to the config ceiling"
6223 );
6224
6225 let empty_override = RunBudgetConfig::default();
6229 assert_eq!(
6230 config_default.merged_with_override(Some(&empty_override)),
6231 config_default,
6232 "an all-absent override body keeps every configured ceiling"
6233 );
6234
6235 let unlimited_default = RunBudgetConfig::default();
6239 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6240 assert_eq!(merged.max_total_tokens, Some(5_000));
6241 assert_eq!(merged.max_tool_calls, None);
6242 assert_eq!(merged.max_subagents, None);
6243 assert_eq!(merged.max_rounds, None);
6244
6245 let tighten_rounds = RunBudgetConfig {
6248 max_total_tokens: None,
6249 max_tool_calls: None,
6250 max_subagents: None,
6251 max_rounds: Some(20),
6252 };
6253 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6254 assert_eq!(merged.max_rounds, Some(20));
6255 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6256 assert_eq!(
6257 merged.max_rounds,
6258 Some(20),
6259 "a stricter per-request round cap wins over the config default"
6260 );
6261 }
6262
6263 #[test]
6264 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6265 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6266 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6267 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6268 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6269
6270 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6271 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6272 assert_eq!(cfg.max_total_tokens, Some(250_000));
6273 assert_eq!(
6274 cfg.max_tool_calls, None,
6275 "absent field defaults to unlimited"
6276 );
6277 assert_eq!(
6278 cfg.max_rounds, None,
6279 "absent round cap defaults to unlimited"
6280 );
6281
6282 assert_eq!(cfg.max_subagents, Some(3));
6283
6284 let json = r#"{ "max_rounds": 250 }"#;
6286 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6287 assert_eq!(cfg.max_rounds, Some(250));
6288 let round_tripped: RunBudgetConfig =
6289 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6290 assert_eq!(round_tripped.max_rounds, Some(250));
6291 assert_eq!(round_tripped.max_total_tokens, None);
6292
6293 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6297 assert_eq!(empty, "{}");
6298 }
6299
6300 #[test]
6301 fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6302 let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6303 assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6304 assert!(!serde_json::to_string(&old)
6305 .unwrap()
6306 .contains("max_spawn_depth"));
6307 for depth in [0, 1, 8] {
6308 let config: SubagentsConfig =
6309 serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6310 assert_eq!(config.effective_max_spawn_depth(), depth);
6311 assert_eq!(
6312 serde_json::to_value(config).unwrap()["max_spawn_depth"],
6313 depth
6314 );
6315 }
6316 for invalid in [
6317 serde_json::json!(-1),
6318 serde_json::json!(1.5),
6319 serde_json::json!("4"),
6320 ] {
6321 assert!(serde_json::from_value::<SubagentsConfig>(
6322 serde_json::json!({"max_spawn_depth": invalid}),
6323 )
6324 .is_err());
6325 }
6326 }
6327
6328 #[test]
6329 fn subagents_config_without_remote_placements_deserializes_empty() {
6330 let json = r#"{ "max_concurrent": 4 }"#;
6333 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6334 assert_eq!(cfg.max_concurrent, Some(4));
6335 assert!(cfg.remote_placements.is_empty());
6336 let back = serde_json::to_string(&cfg).unwrap();
6338 assert!(
6339 !back.contains("remote_placements"),
6340 "empty vec is skipped: {back}"
6341 );
6342 }
6343
6344 #[test]
6345 fn remote_actor_placement_round_trips() {
6346 let json = r#"{
6347 "remote_placements": [
6348 {
6349 "role": "explorer",
6350 "endpoint": "wss://gpu-host:8443",
6351 "token_env": "WORKER_TOKEN",
6352 "ca_cert_file": "/etc/bamboo/worker.pem"
6353 },
6354 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6355 ]
6356 }"#;
6357 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6358 assert_eq!(cfg.remote_placements.len(), 2);
6359 let p0 = &cfg.remote_placements[0];
6360 assert_eq!(p0.role, "explorer");
6361 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6362 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6363 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6364 let p1 = &cfg.remote_placements[1];
6366 assert_eq!(p1.role, "writer");
6367 assert!(p1.token_env.is_none());
6368 assert!(p1.ca_cert_file.is_none());
6369
6370 let back = serde_json::to_string(&cfg).unwrap();
6371 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6372 assert_eq!(cfg, reparsed, "round-trip is stable");
6373 assert!(!back.contains("\"token_env\":null"));
6374 assert!(!back.contains("\"ca_cert_file\":null"));
6375 }
6376
6377 #[test]
6378 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6379 let json = r#"{ "max_concurrent": 4 }"#;
6382 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6383 assert!(cfg.schedulable_placements.is_empty());
6384 let back = serde_json::to_string(&cfg).unwrap();
6386 assert!(
6387 !back.contains("schedulable_placements"),
6388 "empty vec is skipped: {back}"
6389 );
6390 }
6391
6392 #[test]
6393 fn schedulable_placement_round_trips() {
6394 let json = r#"{
6395 "schedulable_placements": [
6396 {
6397 "role": "explorer",
6398 "pool": "gpu-pool",
6399 "registry_url": "https://control-plane:9562",
6400 "token_env": "WORKER_TOKEN",
6401 "ca_cert_file": "/etc/bamboo/worker.pem"
6402 },
6403 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6404 ]
6405 }"#;
6406 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6407 assert_eq!(cfg.schedulable_placements.len(), 2);
6408 let p0 = &cfg.schedulable_placements[0];
6409 assert_eq!(p0.role, "explorer");
6410 assert_eq!(p0.pool, "gpu-pool");
6411 assert_eq!(p0.registry_url, "https://control-plane:9562");
6412 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6413 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6414 let p1 = &cfg.schedulable_placements[1];
6416 assert_eq!(p1.role, "writer");
6417 assert_eq!(p1.pool, "cpu-pool");
6418 assert!(p1.token_env.is_none());
6419 assert!(p1.ca_cert_file.is_none());
6420
6421 let back = serde_json::to_string(&cfg).unwrap();
6422 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6423 assert_eq!(cfg, reparsed, "round-trip is stable");
6424 assert!(!back.contains("\"token_env\":null"));
6425 assert!(!back.contains("\"ca_cert_file\":null"));
6426 }
6427
6428 #[test]
6429 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6430 let json = r#"{ "max_concurrent": 4 }"#;
6434 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6435 assert!(cfg.mcp_role_allowlist.is_empty());
6436 let back = serde_json::to_string(&cfg).unwrap();
6438 assert!(
6439 !back.contains("mcp_role_allowlist"),
6440 "empty vec is skipped: {back}"
6441 );
6442 }
6443
6444 #[test]
6445 fn mcp_role_allowlist_entry_round_trips() {
6446 let json = r#"{
6447 "mcp_role_allowlist": [
6448 { "role": "researcher", "tools": ["fetch_url"] },
6449 { "role": "sandboxed", "tools": [] }
6450 ]
6451 }"#;
6452 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6453 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6454 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6455 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6456 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6459 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6460
6461 let back = serde_json::to_string(&cfg).unwrap();
6462 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6463 assert_eq!(cfg, reparsed, "round-trip is stable");
6464 }
6465
6466 #[test]
6467 fn server_config_without_tls_field_deserializes_back_compat() {
6468 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6471 "port": 9562,
6472 "bind": "127.0.0.1"
6473 }))
6474 .expect("legacy server config without tls should deserialize");
6475
6476 assert_eq!(server.tls, None);
6477 assert_eq!(server.port, 9562);
6478 assert_eq!(server.bind, "127.0.0.1");
6479 }
6480
6481 #[test]
6482 fn server_config_omits_tls_when_none() {
6483 let server = ServerConfig::default();
6486 let value = serde_json::to_value(&server).expect("server config should serialize");
6487 let obj = value
6488 .as_object()
6489 .expect("server config serializes to object");
6490 assert!(
6491 !obj.contains_key("tls"),
6492 "tls must be omitted when None, got: {value}"
6493 );
6494 }
6495
6496 #[test]
6497 fn server_config_with_tls_roundtrips() {
6498 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6499 "port": 9562,
6500 "bind": "0.0.0.0",
6501 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6502 }))
6503 .expect("server config with tls should deserialize");
6504
6505 let tls = server.tls.clone().expect("tls should be Some");
6506 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6507 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6508
6509 let value = serde_json::to_value(&server).expect("serialize");
6511 assert!(value.as_object().unwrap().contains_key("tls"));
6512 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6513 assert_eq!(back.tls, server.tls);
6514 }
6515
6516 #[test]
6517 fn access_control_without_devices_field_deserializes_back_compat() {
6518 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6521 "password_enabled": true,
6522 "password_hash": "deadbeef",
6523 "password_salt": "01020304",
6524 }))
6525 .expect("legacy access_control without devices should deserialize");
6526
6527 assert!(access.devices.is_empty());
6528 assert!(access.password_enabled);
6529 }
6530
6531 #[test]
6532 fn access_control_omits_devices_when_empty() {
6533 let access = AccessControlConfig {
6536 password_enabled: true,
6537 repair_required: false,
6538 password_hash: Some("deadbeef".to_string()),
6539 password_salt: Some("01020304".to_string()),
6540 password_credential_ref: None,
6541 password_configured: false,
6542 updated_at: None,
6543 devices: Vec::new(),
6544 };
6545 let value = serde_json::to_value(&access).expect("serialize");
6546 let obj = value.as_object().expect("object");
6547 assert!(
6548 !obj.contains_key("devices"),
6549 "devices must be omitted when empty, got: {value}"
6550 );
6551 }
6552
6553 #[test]
6554 fn access_control_with_devices_roundtrips() {
6555 let device = DeviceCredential {
6556 device_id: "bamboo_0123456789ab".to_string(),
6557 label: "iPhone 15".to_string(),
6558 token_hash: "abcd".to_string(),
6559 token_salt: "ef01".to_string(),
6560 token_credential_ref: None,
6561 token_configured: false,
6562 created_at: "2026-06-23T00:00:00Z".to_string(),
6563 last_used_at: None,
6564 revoked: false,
6565 };
6566 let access = AccessControlConfig {
6567 password_enabled: true,
6568 repair_required: false,
6569 password_hash: Some("deadbeef".to_string()),
6570 password_salt: Some("01020304".to_string()),
6571 password_credential_ref: None,
6572 password_configured: false,
6573 updated_at: None,
6574 devices: vec![device.clone()],
6575 };
6576 let value = serde_json::to_value(&access).expect("serialize");
6577 assert!(value.as_object().unwrap().contains_key("devices"));
6578 assert!(value["devices"][0].get("token_hash").is_none());
6579 assert!(value["devices"][0].get("token_salt").is_none());
6580 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6581 assert_eq!(back.devices[0].device_id, device.device_id);
6582 assert!(back.devices[0].token_hash.is_empty());
6583 assert!(back.devices[0].token_salt.is_empty());
6584 }
6585
6586 #[test]
6587 fn reasoning_effort_for_key_resolves_instance_id() {
6588 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6592 "provider_type": "copilot",
6593 "reasoning_effort": "high",
6594 }))
6595 .expect("instance config should deserialize");
6596
6597 let mut config = Config::create_default();
6598 config
6599 .provider_instances
6600 .insert("copilot-work".to_string(), instance);
6601
6602 assert_eq!(
6603 config.reasoning_effort_for_key("copilot-work"),
6604 Some(ReasoningEffort::High),
6605 );
6606 }
6607
6608 #[test]
6609 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6610 let mut config = Config::create_default();
6612 config.providers.bodhi = Some(
6613 serde_json::from_value(serde_json::json!({
6614 "reasoning_effort": "xhigh",
6615 }))
6616 .expect("bodhi config should deserialize"),
6617 );
6618
6619 assert_eq!(
6620 config.reasoning_effort_for_key("bodhi"),
6621 Some(ReasoningEffort::Xhigh),
6622 );
6623 }
6624
6625 #[test]
6626 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6627 let mut config = Config::create_default();
6628 config.providers.openai = Some(
6629 serde_json::from_value(serde_json::json!({
6630 "api_key": "sk-test",
6631 "reasoning_effort": "low",
6632 }))
6633 .expect("openai config should deserialize"),
6634 );
6635
6636 assert_eq!(
6637 config.reasoning_effort_for_key("openai"),
6638 Some(ReasoningEffort::Low),
6639 );
6640 }
6641
6642 #[test]
6643 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6644 let config = Config::create_default();
6645 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6646 assert_eq!(config.reasoning_effort_for_key(" "), None);
6647 }
6648
6649 struct TempHome {
6650 path: PathBuf,
6651 }
6652
6653 impl TempHome {
6654 fn new() -> Self {
6655 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6667 std::sync::atomic::AtomicU64::new(0);
6668 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6669 let nanos = SystemTime::now()
6670 .duration_since(UNIX_EPOCH)
6671 .expect("clock should be after unix epoch")
6672 .as_nanos();
6673 let path = std::env::temp_dir().join(format!(
6674 "chat-core-config-test-{}-{}-{}",
6675 std::process::id(),
6676 nanos,
6677 unique
6678 ));
6679 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6680 Self { path }
6681 }
6682
6683 fn set_config_json(&self, content: &str) {
6684 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6687 std::fs::write(self.path.join("config.json"), content)
6688 .expect("failed to write config.json");
6689 }
6690 }
6691
6692 impl Drop for TempHome {
6693 fn drop(&mut self) {
6694 let _ = std::fs::remove_dir_all(&self.path);
6695 }
6696 }
6697
6698 fn env_lock() -> &'static Mutex<()> {
6702 crate::test_support::env_cache_lock()
6703 }
6704
6705 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6707 env_lock().lock().unwrap_or_else(|poisoned| {
6708 poisoned.into_inner()
6710 })
6711 }
6712
6713 #[test]
6714 fn parse_bool_env_true_values() {
6715 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6716 assert!(parse_bool_env(value), "value {value:?} should be true");
6717 }
6718 }
6719
6720 #[test]
6721 fn parse_bool_env_false_values() {
6722 for value in ["0", "false", "no", "off", "", " "] {
6723 assert!(!parse_bool_env(value), "value {value:?} should be false");
6724 }
6725 }
6726
6727 #[test]
6728 fn config_new_ignores_http_proxy_env_vars() {
6729 let _lock = env_lock_acquire();
6730 let temp_home = TempHome::new();
6731 temp_home.set_config_json(
6732 r#"{
6733 "http_proxy": "",
6734 "https_proxy": ""
6735}"#,
6736 );
6737
6738 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6739 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6740
6741 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6742
6743 assert!(
6744 config.http_proxy.is_empty(),
6745 "config should ignore HTTP_PROXY env var"
6746 );
6747 assert!(
6748 config.https_proxy.is_empty(),
6749 "config should ignore HTTPS_PROXY env var"
6750 );
6751 }
6752
6753 #[test]
6754 fn config_new_loads_config_when_proxy_fields_omitted() {
6755 let _lock = env_lock_acquire();
6756 let temp_home = TempHome::new();
6757 temp_home.set_config_json(
6758 r#"{
6759 "provider": "openai",
6760 "providers": {
6761 "openai": {
6762 "api_key": "sk-test",
6763 "model": "gpt-4o"
6764 }
6765 }
6766}"#,
6767 );
6768
6769 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6770 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6771
6772 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6773
6774 assert_eq!(
6775 config
6776 .providers
6777 .openai
6778 .as_ref()
6779 .and_then(|c| c.model.as_deref()),
6780 Some("gpt-4o"),
6781 "config should load provider model from config file even when proxy fields are omitted"
6782 );
6783 assert!(config.http_proxy.is_empty());
6784 assert!(config.https_proxy.is_empty());
6785 }
6786
6787 #[test]
6788 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6789 let _lock = crate::test_support::env_cache_lock_acquire();
6790 let mut config = Config::default();
6791 config.env_vars.extend([
6792 EnvVarEntry {
6793 name: "SECRET_TOKEN".to_string(),
6794 value: "top-secret".to_string(),
6795 secret: true,
6796 value_encrypted: None,
6797 credential_ref: None,
6798 configured: true,
6799 description: Some("Service token".to_string()),
6800 },
6801 EnvVarEntry {
6802 name: "API_BASE".to_string(),
6803 value: "https://internal.example".to_string(),
6804 secret: false,
6805 value_encrypted: None,
6806 credential_ref: None,
6807 configured: true,
6808 description: Some("Internal API base".to_string()),
6809 },
6810 ]);
6811
6812 config.publish_env_vars();
6813
6814 let injected = Config::current_env_vars();
6815 assert_eq!(
6816 injected.get("SECRET_TOKEN").map(String::as_str),
6817 Some("top-secret")
6818 );
6819 assert_eq!(
6820 injected.get("API_BASE").map(String::as_str),
6821 Some("https://internal.example")
6822 );
6823
6824 let prompt_safe = Config::current_prompt_safe_env_vars();
6825 assert_eq!(prompt_safe.len(), 2);
6826 assert!(prompt_safe.iter().any(|entry| {
6827 entry.name == "SECRET_TOKEN"
6828 && entry.secret
6829 && entry.description.as_deref() == Some("Service token")
6830 }));
6831 assert!(prompt_safe.iter().any(|entry| {
6832 entry.name == "API_BASE"
6833 && !entry.secret
6834 && entry.description.as_deref() == Some("Internal API base")
6835 }));
6836 assert!(!prompt_safe
6837 .iter()
6838 .any(|entry| entry.name.contains("top-secret")));
6839 assert!(!prompt_safe.iter().any(|entry| {
6840 entry
6841 .description
6842 .as_deref()
6843 .is_some_and(|value| value.contains("https://internal.example"))
6844 }));
6845 }
6846
6847 #[test]
6848 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6849 let _lock = crate::test_support::env_cache_lock_acquire();
6850
6851 let mut live = Config::default();
6853 live.env_vars.extend([EnvVarEntry {
6854 name: "BAMBOO_CACHE_OWNER_40".to_string(),
6855 value: "live".to_string(),
6856 secret: false,
6857 value_encrypted: None,
6858 credential_ref: None,
6859 configured: true,
6860 description: None,
6861 }]);
6862 live.publish_env_vars();
6863 assert_eq!(
6864 Config::current_env_vars()
6865 .get("BAMBOO_CACHE_OWNER_40")
6866 .map(String::as_str),
6867 Some("live")
6868 );
6869
6870 let temp = TempHome::new();
6872 temp.set_config_json(
6873 &serde_json::json!({
6874 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6875 })
6876 .to_string(),
6877 );
6878
6879 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6882 assert_eq!(
6883 loaded
6884 .env_vars
6885 .iter()
6886 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6887 .map(|e| e.value.as_str()),
6888 Some("stale-disk"),
6889 "the returned Config holds the disk value"
6890 );
6891 assert_eq!(
6892 Config::current_env_vars()
6893 .get("BAMBOO_CACHE_OWNER_40")
6894 .map(String::as_str),
6895 Some("live"),
6896 "but the global cache is UNTOUCHED — no clobber (#40)"
6897 );
6898
6899 let _ = Config::from_data_dir(Some(temp.path.clone()));
6901 assert_eq!(
6902 Config::current_env_vars()
6903 .get("BAMBOO_CACHE_OWNER_40")
6904 .map(String::as_str),
6905 Some("stale-disk"),
6906 "the publishing loader clobbers the cache (contrast)"
6907 );
6908 }
6909
6910 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6911 std::fs::read_dir(dir)
6912 .unwrap()
6913 .filter_map(|e| e.ok())
6914 .any(|e| {
6915 e.file_name()
6916 .to_string_lossy()
6917 .contains("config.json.corrupted.")
6918 })
6919 }
6920
6921 #[test]
6922 fn corrupt_config_recovered_from_backup_and_quarantined() {
6923 let temp = TempHome::new();
6924 std::fs::write(
6926 temp.path.join("config.json.bak"),
6927 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6928 )
6929 .unwrap();
6930 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6932
6933 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6934 assert_eq!(
6935 config.http_proxy, "http://from-backup",
6936 "recovered from config.json.bak instead of losing all config"
6937 );
6938 assert!(
6939 dir_has_quarantine_file(&temp.path),
6940 "corrupt config.json was quarantined (preserved), not discarded"
6941 );
6942 }
6943
6944 #[test]
6945 fn corrupt_config_without_backup_quarantines_then_defaults() {
6946 let temp = TempHome::new();
6947 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6948
6949 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6950 assert!(
6951 config.http_proxy.is_empty(),
6952 "no backup -> falls back to defaults"
6953 );
6954 assert!(
6955 dir_has_quarantine_file(&temp.path),
6956 "corrupt config.json is quarantined even when there's no backup"
6957 );
6958 }
6959
6960 #[test]
6961 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6962 let temp = TempHome::new();
6963 temp.set_config_json(
6968 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6969 );
6970
6971 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6972 assert_eq!(
6973 config.http_proxy, "http://salvaged",
6974 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
6975 );
6976 assert!(
6977 config.env_vars.is_empty(),
6978 "the corrupt field reset to its default instead of failing the whole load"
6979 );
6980 assert!(
6981 dir_has_quarantine_file(&temp.path),
6982 "the corrupt config.json was still quarantined for inspection"
6983 );
6984 }
6985
6986 #[test]
6987 fn salvage_preferred_over_backup_for_most_recent_intent() {
6988 let temp = TempHome::new();
6989 std::fs::write(
6991 temp.path.join("config.json.bak"),
6992 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
6993 )
6994 .unwrap();
6995 temp.set_config_json(
6997 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
6998 );
6999
7000 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7001 assert_eq!(
7002 config.http_proxy, "http://new-salvaged",
7003 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7004 );
7005 }
7006
7007 #[test]
7008 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7009 let temp = TempHome::new();
7010 std::fs::write(
7012 temp.path.join("config.json.bak"),
7013 serde_json::json!({
7014 "http_proxy": "http://old-from-backup",
7015 "https_proxy": "https://kept-from-backup",
7016 })
7017 .to_string(),
7018 )
7019 .unwrap();
7020 temp.set_config_json(
7023 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7024 );
7025
7026 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7027 assert_eq!(
7029 config.http_proxy, "http://newer-edit",
7030 "the corrupt file's newer valid edit is applied"
7031 );
7032 assert_eq!(
7034 config.https_proxy, "https://kept-from-backup",
7035 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7036 );
7037 }
7038
7039 #[test]
7040 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7041 let temp = TempHome::new();
7042 std::fs::write(
7043 temp.path.join("config.json.bak"),
7044 serde_json::json!({
7045 "providers": {
7046 "anthropic": { "model": "claude-backup" }
7047 },
7048 "memory": {
7049 "auto_dream_enabled": true
7050 },
7051 "subagents": {
7052 "claude_code_model": "claude-code-backup"
7053 }
7054 })
7055 .to_string(),
7056 )
7057 .unwrap();
7058 assert!(!temp.path.join("providers.json").exists());
7059 assert!(!temp.path.join("memory.json").exists());
7060 assert!(!temp.path.join("subagents.json").exists());
7061
7062 let (salvaged, recovered_fields) = Config::salvage_partial(
7063 r#"{"providers":"schema-invalid-provider-section"}"#,
7064 &temp.path,
7065 )
7066 .expect("object-shaped corrupt config should be salvageable");
7067
7068 assert!(
7069 recovered_fields.is_empty(),
7070 "the schema-invalid provider field must not replace the backup baseline"
7071 );
7072 assert_eq!(
7073 salvaged
7074 .providers()
7075 .anthropic
7076 .as_ref()
7077 .and_then(|provider| provider.model.as_deref()),
7078 Some("claude-backup")
7079 );
7080 assert!(
7081 salvaged
7082 .memory()
7083 .as_ref()
7084 .expect("backup memory config survives")
7085 .auto_dream_enabled
7086 );
7087 assert_eq!(
7088 salvaged.subagents().claude_code_model.as_deref(),
7089 Some("claude-code-backup")
7090 );
7091 }
7092
7093 #[test]
7094 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7095 let temp = TempHome::new();
7096 std::fs::write(
7099 temp.path.join("config.json.bak"),
7100 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7101 )
7102 .unwrap();
7103 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7104
7105 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7106 assert_eq!(
7107 config.http_proxy, "http://from-backup",
7108 "garbage (non-object) config skips salvage and recovers from .bak"
7109 );
7110 }
7111
7112 #[test]
7113 fn quarantine_files_are_capped_to_newest_n() {
7114 let temp = TempHome::new();
7115 let config_path = temp.path.join("config.json");
7116 std::fs::write(&config_path, "{}").unwrap();
7117
7118 for _ in 0..(QUARANTINE_KEEP + 3) {
7121 quarantine_corrupt_config(&config_path);
7122 std::thread::sleep(std::time::Duration::from_millis(3));
7123 }
7124
7125 let count = std::fs::read_dir(&temp.path)
7126 .unwrap()
7127 .filter_map(|e| e.ok())
7128 .filter(|e| {
7129 e.file_name()
7130 .to_string_lossy()
7131 .starts_with("config.json.corrupted.")
7132 })
7133 .count();
7134 assert_eq!(
7135 count, QUARANTINE_KEEP,
7136 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7137 );
7138 }
7139
7140 #[test]
7141 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7142 let temp = TempHome::new();
7143 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7145 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7146 std::fs::write(
7147 temp.path.join("config.json.bak.1"),
7148 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7149 )
7150 .unwrap();
7151
7152 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7153 assert_eq!(
7154 config.http_proxy, "http://from-gen-1",
7155 "recovered from .bak.1 when both config.json and .bak are corrupt"
7156 );
7157 }
7158
7159 #[test]
7160 fn save_rotates_backup_generations() {
7161 let temp = TempHome::new();
7162 let path = temp.path.join("config.json");
7163 std::fs::write(
7165 &path,
7166 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7167 )
7168 .unwrap();
7169
7170 let mut cfg = Config::create_default();
7171 cfg.http_proxy = "http://proxy-v2".to_string();
7173 cfg.save_to_dir(temp.path.clone()).unwrap();
7174 cfg.http_proxy = "http://proxy-v3".to_string();
7176 cfg.save_to_dir(temp.path.clone()).unwrap();
7177
7178 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7179 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7180 assert!(
7181 bak.contains("proxy-v2"),
7182 ".bak holds the previous generation (v2)"
7183 );
7184 assert!(
7185 bak1.contains("proxy-v1"),
7186 ".bak.1 holds the older rotated generation (v1)"
7187 );
7188 }
7189
7190 #[test]
7191 fn save_backs_up_existing_config() {
7192 let temp = TempHome::new();
7193 std::fs::write(
7195 temp.path.join("config.json"),
7196 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7197 )
7198 .unwrap();
7199
7200 let mut config = Config::create_default();
7201 config.http_proxy = "http://new".to_string();
7202 config
7203 .save_to_dir(temp.path.clone())
7204 .expect("save succeeds");
7205
7206 let backup =
7207 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7208 assert!(
7209 backup.contains("http://old"),
7210 "config.json.bak holds the PREVIOUS config (last-known-good)"
7211 );
7212 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7213 assert!(
7214 current.contains("http://new"),
7215 "config.json holds the new config"
7216 );
7217 }
7218
7219 #[test]
7220 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7221 let temp = TempHome::new();
7222 std::fs::write(
7224 temp.path.join("config.json.bak"),
7225 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7226 )
7227 .unwrap();
7228 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7231
7232 let mut config = Config::create_default();
7233 config.http_proxy = "http://new".to_string();
7234 config
7235 .save_to_dir(temp.path.clone())
7236 .expect("save succeeds");
7237
7238 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7240 assert!(
7241 backup.contains("http://good-bak"),
7242 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7243 );
7244 }
7245
7246 #[test]
7249 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7250 let temp = TempHome::new();
7251 std::fs::write(
7252 temp.path.join("config.json.bak"),
7253 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7254 )
7255 .unwrap();
7256 let corrupt_bytes = "{ not valid json ";
7257 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7258
7259 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7260 let status = config
7261 .recovery_status()
7262 .expect("a corrupt load must set a pending recovery status");
7263 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7264 assert_eq!(
7265 status.source,
7266 ConfigRecoverySource::Backup { generation: 0 },
7267 "recovered from generation-0 (.bak)"
7268 );
7269 let quarantine_path = status
7270 .quarantine_path
7271 .as_ref()
7272 .expect("quarantine copy should have succeeded");
7273 assert_eq!(
7274 std::fs::read_to_string(quarantine_path).unwrap(),
7275 corrupt_bytes,
7276 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7277 );
7278 assert_eq!(
7279 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7280 corrupt_bytes,
7281 "the original config.json itself is untouched by the load (only copied, not moved)"
7282 );
7283 }
7284
7285 #[test]
7286 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7287 let temp = TempHome::new();
7288 temp.set_config_json(
7289 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7290 );
7291
7292 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7293 let status = config.recovery_status().expect("pending recovery");
7294 assert!(!status.confirmed);
7295 match &status.source {
7296 ConfigRecoverySource::Salvaged { fields } => {
7297 assert!(
7298 fields.iter().any(|f| f == "http_proxy"),
7299 "salvaged fields should list the recovered key: {fields:?}"
7300 );
7301 }
7302 other => panic!("expected Salvaged source, got {other:?}"),
7303 }
7304 }
7305
7306 #[test]
7307 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7308 let temp = TempHome::new();
7309 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7311
7312 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7313 let status = config.recovery_status().expect("pending recovery");
7314 assert!(!status.confirmed);
7315 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7316 }
7317
7318 #[test]
7319 fn clean_load_never_sets_recovery_status() {
7320 let temp = TempHome::new();
7321 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7322
7323 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7324 assert!(
7325 config.recovery_status().is_none(),
7326 "a config.json that parses cleanly must never carry a pending recovery status"
7327 );
7328 }
7329
7330 #[test]
7331 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7332 let temp = TempHome::new();
7333 let corrupt_bytes = "}}} broken";
7334 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7335
7336 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7337 assert!(config.recovery_status().is_some());
7338
7339 let err = config
7340 .save_to_dir(temp.path.clone())
7341 .expect_err("save must refuse while recovery is unconfirmed");
7342 assert!(
7343 err.to_string().contains("recovered from corruption")
7344 || err.to_string().contains("confirm"),
7345 "error should explain the refused overwrite: {err}"
7346 );
7347
7348 assert_eq!(
7351 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7352 corrupt_bytes,
7353 "a refused save must leave the corrupt original untouched"
7354 );
7355 }
7356
7357 #[test]
7358 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7359 let temp = TempHome::new();
7360 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7362 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7363
7364 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7365 let status = config.recovery_status().expect("pending recovery");
7366 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7367 assert_eq!(
7368 std::fs::read_to_string(quarantine_path).unwrap(),
7369 truncated,
7370 "truncated original preserved byte for byte in quarantine"
7371 );
7372
7373 let err = config.save_to_dir(temp.path.clone());
7374 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7375 assert_eq!(
7376 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7377 truncated,
7378 "the half-written original stays exactly as it was after a refused save"
7379 );
7380 }
7381
7382 #[test]
7383 fn confirm_recovery_allows_the_next_save() {
7384 let temp = TempHome::new();
7385 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7386
7387 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7388 assert!(config.recovery_status().is_some());
7389
7390 config.confirm_recovery();
7391 assert!(
7392 config.recovery_status().is_some_and(|s| s.confirmed),
7393 "confirm_recovery flips the flag but keeps the status around"
7394 );
7395
7396 config
7397 .save_to_dir(temp.path.clone())
7398 .expect("save must succeed once the recovery is confirmed");
7399 }
7400
7401 #[test]
7402 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7403 let temp = TempHome::new();
7404 std::fs::write(
7405 temp.path.join("config.json"),
7406 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7407 )
7408 .unwrap();
7409
7410 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7411 assert!(config.recovery_status().is_some());
7412 let quarantine_path = config
7413 .recovery_status()
7414 .unwrap()
7415 .quarantine_path
7416 .clone()
7417 .unwrap();
7418
7419 config
7420 .confirm_recovery_and_save_to_dir(temp.path.clone())
7421 .expect("confirm+save should succeed");
7422
7423 assert!(
7424 config.recovery_status().is_none(),
7425 "the pending flag is cleared once the recovery is confirmed and persisted"
7426 );
7427 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7428 assert!(
7429 on_disk.contains("http://recovered"),
7430 "config.json now holds the recovered (salvaged) state"
7431 );
7432 assert!(
7435 quarantine_path.exists(),
7436 "the quarantined original survives confirmation — it's never deleted"
7437 );
7438 }
7439
7440 #[test]
7441 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7442 let temp = TempHome::new();
7443 let mut config = Config::create_default();
7444 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7445 assert!(
7446 err.is_err(),
7447 "confirming a recovery that was never pending must error, not silently succeed"
7448 );
7449 }
7450
7451 fn connect_platform_with_encrypted(
7454 platform_type: &str,
7455 token_encrypted: &str,
7456 ) -> ConnectPlatformConfig {
7457 ConnectPlatformConfig {
7458 id: None,
7459 project_id: None,
7460 platform_type: platform_type.to_string(),
7461 token: None,
7462 token_encrypted: Some(token_encrypted.to_string()),
7463 token_credential_ref: None,
7464 token_configured: false,
7465 app_id: None,
7466 app_secret: None,
7467 app_secret_encrypted: None,
7468 app_secret_credential_ref: None,
7469 app_secret_configured: false,
7470 domain: None,
7471 allow_from: vec!["user-1".to_string()],
7472 admin_from: Vec::new(),
7473 }
7474 }
7475
7476 fn connect_json_path(temp: &TempHome) -> PathBuf {
7477 temp.path.join("connect.json")
7478 }
7479
7480 #[test]
7481 fn save_splits_connect_into_sibling_connect_json() {
7482 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7483 let temp = TempHome::new();
7484
7485 let mut config = Config::create_default();
7486 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7487 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7488
7489 config
7490 .save_to_dir(temp.path.clone())
7491 .expect("save succeeds");
7492
7493 let config_json: serde_json::Value =
7494 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7495 .unwrap();
7496 assert!(
7497 config_json.get("connect").is_none(),
7498 "config.json must not carry the `connect` key after a save"
7499 );
7500
7501 let connect_json: serde_json::Value =
7502 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7503 .unwrap();
7504 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7505 assert!(
7506 connect_json["platforms"][0]["token_encrypted"]
7507 .as_str()
7508 .is_some_and(|v| !v.is_empty()),
7509 "the token is persisted in its encrypted form in connect.json"
7510 );
7511 assert!(
7512 connect_json["platforms"][0].get("token").is_none(),
7513 "the plaintext token is never persisted (skip_serializing)"
7514 );
7515 }
7516
7517 #[test]
7520 fn save_assigns_a_missing_connect_platform_id() {
7521 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7522 let temp = TempHome::new();
7523
7524 let mut config = Config::create_default();
7525 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7526 assert!(
7527 config.connect.platforms[0].id.is_none(),
7528 "precondition: the entry starts without an id"
7529 );
7530
7531 config
7532 .save_to_dir(temp.path.clone())
7533 .expect("save succeeds");
7534
7535 let connect_json: serde_json::Value =
7536 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7537 .unwrap();
7538 let persisted_id = connect_json["platforms"][0]["id"]
7539 .as_str()
7540 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7541 assert!(!persisted_id.is_empty());
7542
7543 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7544 assert_eq!(
7545 reloaded.connect.platforms[0].id.as_deref(),
7546 Some(persisted_id),
7547 "the assigned id round-trips through a reload"
7548 );
7549 }
7550
7551 #[test]
7552 fn save_never_reassigns_an_existing_connect_platform_id() {
7553 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7554 let temp = TempHome::new();
7555
7556 let mut config = Config::create_default();
7557 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7558 platform.id = Some("stable-id-123".to_string());
7559 config.connect.platforms = vec![platform];
7560
7561 config
7562 .save_to_dir(temp.path.clone())
7563 .expect("first save succeeds");
7564 config
7566 .save_to_dir(temp.path.clone())
7567 .expect("second save succeeds");
7568
7569 let connect_json: serde_json::Value =
7570 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7571 .unwrap();
7572 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7573 }
7574
7575 #[test]
7576 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7577 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7578 let temp = TempHome::new();
7579
7580 let mut config = Config::create_default();
7581 config.connect.platforms = vec![
7582 connect_platform_with_encrypted("telegram", "cipher-a"),
7583 connect_platform_with_encrypted("telegram", "cipher-b"),
7584 ];
7585
7586 config
7587 .save_to_dir(temp.path.clone())
7588 .expect("save succeeds");
7589
7590 let connect_json: serde_json::Value =
7591 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7592 .unwrap();
7593 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7594 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7595 assert_ne!(
7596 id_a, id_b,
7597 "two entries sharing platform_type must still get distinct ids"
7598 );
7599 }
7600
7601 #[test]
7602 fn load_never_assigns_or_persists_an_id_by_itself() {
7603 let temp = TempHome::new();
7604 std::fs::write(
7605 connect_json_path(&temp),
7606 serde_json::json!({
7607 "platforms": [
7608 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7609 ]
7610 })
7611 .to_string(),
7612 )
7613 .unwrap();
7614 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7615
7616 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7617
7618 assert!(
7619 config.connect.platforms[0].id.is_none(),
7620 "load alone must not backfill an id in memory"
7621 );
7622 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7623 assert_eq!(
7624 connect_json_before, connect_json_after,
7625 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7626 );
7627 }
7628
7629 #[test]
7630 fn load_merges_connect_json_into_config() {
7631 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7632 let temp = TempHome::new();
7633 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7634 std::fs::write(
7635 connect_json_path(&temp),
7636 serde_json::json!({
7637 "platforms": [
7638 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7639 ]
7640 })
7641 .to_string(),
7642 )
7643 .unwrap();
7644
7645 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7646 assert_eq!(config.connect.platforms.len(), 1);
7647 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7648 assert_eq!(
7649 config.connect.platforms[0].token.as_deref(),
7650 Some("connect-secret")
7651 );
7652 assert!(config.connect.platforms[0].token_encrypted.is_none());
7653 }
7654
7655 #[test]
7656 fn load_without_connect_json_yields_empty_inert_connect_config() {
7657 let temp = TempHome::new();
7658 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7659
7660 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7661 assert!(
7662 config.connect.platforms.is_empty(),
7663 "no connect.json and no legacy key -> empty/inert connect config"
7664 );
7665 assert!(
7666 !connect_json_path(&temp).exists(),
7667 "load must not create connect.json when there is nothing to migrate"
7668 );
7669 }
7670
7671 #[test]
7672 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7673 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7674 let temp = TempHome::new();
7675 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7676 let legacy_cipher_for_assert = legacy_cipher.clone();
7677 temp.set_config_json(
7679 &serde_json::json!({
7680 "http_proxy": "http://keep-me",
7681 "connect": {
7682 "platforms": [
7683 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7684 ]
7685 }
7686 })
7687 .to_string(),
7688 );
7689
7690 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7691
7692 assert_eq!(config.connect.platforms.len(), 1);
7694 assert_eq!(
7695 config.connect.platforms[0].token.as_deref(),
7696 Some("legacy-connect-secret")
7697 );
7698 assert_eq!(config.http_proxy, "http://keep-me");
7700
7701 assert!(
7703 connect_json_path(&temp).exists(),
7704 "migration proactively creates connect.json"
7705 );
7706 let connect_json: serde_json::Value =
7707 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7708 .unwrap();
7709 assert_eq!(
7710 connect_json["platforms"][0]["token_encrypted"],
7711 legacy_cipher_for_assert
7712 );
7713
7714 let config_json: serde_json::Value =
7716 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7717 .unwrap();
7718 assert!(
7719 config_json.get("connect").is_none(),
7720 "config.json is rewritten without the legacy `connect` key"
7721 );
7722 }
7723
7724 #[test]
7732 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7733 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7734 let temp = TempHome::new();
7735
7736 let original_api_key_encrypted =
7737 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7738 temp.set_config_json(
7739 &serde_json::json!({
7740 "providers": {
7741 "openai": {
7742 "api_key_encrypted": original_api_key_encrypted,
7743 }
7744 },
7745 "connect": {
7746 "platforms": [
7747 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7748 ]
7749 }
7750 })
7751 .to_string(),
7752 );
7753
7754 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7755 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7756
7757 assert!(
7760 !temp.path.join("config.json.bak").exists(),
7761 "a read-only load migrating a legacy `connect` key must not rotate \
7762 config.json backups"
7763 );
7764
7765 let config_json: serde_json::Value =
7770 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7771 .unwrap();
7772 assert_eq!(
7773 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7774 "an unrelated secret's ciphertext must not be touched by the connect \
7775 migration's narrow write"
7776 );
7777 assert!(
7778 config_json.get("connect").is_none(),
7779 "config.json is still rewritten without the legacy `connect` key"
7780 );
7781 }
7782
7783 #[test]
7784 fn both_files_present_connect_json_wins() {
7785 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7786 let temp = TempHome::new();
7787 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7788 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7789 temp.set_config_json(
7790 &serde_json::json!({
7791 "connect": {
7792 "platforms": [
7793 { "type": "telegram", "token_encrypted": stale }
7794 ]
7795 }
7796 })
7797 .to_string(),
7798 );
7799 std::fs::write(
7800 connect_json_path(&temp),
7801 serde_json::json!({
7802 "platforms": [
7803 { "type": "telegram", "token_encrypted": authoritative }
7804 ]
7805 })
7806 .to_string(),
7807 )
7808 .unwrap();
7809
7810 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7811 assert_eq!(
7812 config.connect.platforms[0].token.as_deref(),
7813 Some("authoritative-secret"),
7814 "connect.json wins over a stale legacy config.json key"
7815 );
7816 }
7817
7818 #[test]
7823 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7824 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7825 let temp = TempHome::new();
7826 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7827 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7828 temp.set_config_json(
7829 &serde_json::json!({
7830 "http_proxy": "http://keep-me",
7831 "connect": {
7832 "platforms": [
7833 { "type": "telegram", "token_encrypted": stale }
7834 ]
7835 }
7836 })
7837 .to_string(),
7838 );
7839 std::fs::write(
7840 connect_json_path(&temp),
7841 serde_json::json!({
7842 "platforms": [
7843 { "type": "telegram", "token_encrypted": authoritative }
7844 ]
7845 })
7846 .to_string(),
7847 )
7848 .unwrap();
7849
7850 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7851 assert_eq!(
7852 config.connect.platforms[0].token.as_deref(),
7853 Some("authoritative-secret")
7854 );
7855 assert_eq!(config.http_proxy, "http://keep-me");
7857
7858 let config_json: serde_json::Value =
7859 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7860 .unwrap();
7861 assert!(
7862 config_json.get("connect").is_none(),
7863 "the stale legacy `connect` key must be stripped from config.json \
7864 immediately on load, not left for the next natural save"
7865 );
7866 }
7867
7868 #[test]
7876 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7877 let temp = TempHome::new();
7878 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7879 std::fs::write(
7880 temp.path.join("config.json.bak"),
7881 serde_json::json!({
7882 "http_proxy": "http://old",
7883 "connect": {
7884 "platforms": [
7885 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7886 ]
7887 }
7888 })
7889 .to_string(),
7890 )
7891 .unwrap();
7892
7893 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7894 assert!(config.connect.platforms.is_empty());
7897
7898 let bak: serde_json::Value = serde_json::from_str(
7899 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7900 )
7901 .unwrap();
7902 assert!(
7903 bak.get("connect").is_none(),
7904 "the legacy `connect` key must be stripped from the tainted .bak generation"
7905 );
7906 assert_eq!(
7907 bak["http_proxy"], "http://old",
7908 "every other key in the .bak generation survives the scrub byte-for-byte in content"
7909 );
7910 }
7911
7912 #[test]
7917 fn scrub_reaches_all_rotated_generations() {
7918 let temp = TempHome::new();
7919 temp.set_config_json(&serde_json::json!({}).to_string());
7920 for (gen_suffix, cipher) in [
7921 ("config.json.bak", "cipher-gen0"),
7922 ("config.json.bak.1", "cipher-gen1"),
7923 ("config.json.bak.2", "cipher-gen2"),
7924 ] {
7925 std::fs::write(
7926 temp.path.join(gen_suffix),
7927 serde_json::json!({
7928 "connect": {
7929 "platforms": [
7930 { "type": "telegram", "token_encrypted": cipher }
7931 ]
7932 }
7933 })
7934 .to_string(),
7935 )
7936 .unwrap();
7937 }
7938
7939 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7940
7941 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7942 let value: serde_json::Value =
7943 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7944 .unwrap();
7945 assert!(
7946 value.get("connect").is_none(),
7947 "{gen_suffix} must have its legacy `connect` key stripped"
7948 );
7949 }
7950 }
7951
7952 #[test]
7960 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7961 let temp = TempHome::new();
7962 temp.set_config_json(&serde_json::json!({}).to_string());
7963 let bak_path = temp.path.join("config.json.bak");
7964 std::fs::write(
7965 &bak_path,
7966 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7967 )
7968 .unwrap();
7969
7970 let before_bytes = std::fs::read(&bak_path).unwrap();
7971 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7972
7973 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7977
7978 let after_bytes = std::fs::read(&bak_path).unwrap();
7979 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7980 assert_eq!(
7981 before_bytes, after_bytes,
7982 "a .bak generation without a legacy `connect` key must not be rewritten at all"
7983 );
7984 assert_eq!(
7985 before_mtime, after_mtime,
7986 "no write means no mtime change either"
7987 );
7988 }
7989
7990 #[test]
7995 fn scrub_skips_unparsable_backup_without_deleting_it() {
7996 let temp = TempHome::new();
7997 temp.set_config_json(&serde_json::json!({}).to_string());
7998 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
7999
8000 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8001
8002 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8003 assert_eq!(
8004 content, "{ not valid json",
8005 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8006 );
8007 }
8008
8009 #[test]
8013 fn scrub_tolerates_missing_generations() {
8014 let temp = TempHome::new();
8015 temp.set_config_json(&serde_json::json!({}).to_string());
8016 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8018 assert!(config.connect.platforms.is_empty());
8019 assert!(!temp.path.join("config.json.bak").exists());
8020 }
8021
8022 #[test]
8025 fn scrub_does_not_break_backup_rotation() {
8026 let temp = TempHome::new();
8027 std::fs::write(
8028 temp.path.join("config.json"),
8029 serde_json::json!({
8030 "http_proxy": "http://proxy-v1",
8031 "connect": {
8032 "platforms": [
8033 { "type": "telegram", "token_encrypted": "legacy-cipher" }
8034 ]
8035 }
8036 })
8037 .to_string(),
8038 )
8039 .unwrap();
8040 std::fs::write(
8041 temp.path.join("config.json.bak"),
8042 serde_json::json!({
8043 "http_proxy": "http://proxy-v0",
8044 "connect": {
8045 "platforms": [
8046 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8047 ]
8048 }
8049 })
8050 .to_string(),
8051 )
8052 .unwrap();
8053
8054 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8056 let bak: serde_json::Value = serde_json::from_str(
8057 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8058 )
8059 .unwrap();
8060 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8061
8062 config.http_proxy = "http://proxy-v2".to_string();
8065 config.save_to_dir(temp.path.clone()).unwrap();
8066
8067 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8068 assert!(
8069 new_bak.contains("proxy-v1"),
8070 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8071 );
8072 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8073 assert!(
8074 new_bak1.contains("proxy-v0"),
8075 ".bak.1 holds the scrubbed older generation after rotation"
8076 );
8077 assert!(
8078 !new_bak1.contains("legacy-bak-cipher"),
8079 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8080 stripped secret"
8081 );
8082 }
8083
8084 #[test]
8085 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8086 let temp = TempHome::new();
8087 temp.set_config_json("{}");
8088 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8089
8090 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8091 assert!(
8092 config.connect.platforms.is_empty(),
8093 "corrupt connect.json fails SAFE to an empty/inert connect config"
8094 );
8095
8096 let backup = connect_json_path(&temp).with_extension("json.bak");
8097 assert!(
8098 backup.exists(),
8099 "the corrupt connect.json is quarantined to connect.json.bak"
8100 );
8101 assert!(
8102 std::fs::read_to_string(backup)
8103 .unwrap()
8104 .contains("not valid json"),
8105 "the quarantined copy holds the bad content"
8106 );
8107 assert!(
8112 !connect_json_path(&temp).exists(),
8113 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8114 connect.json should remain after quarantine"
8115 );
8116 }
8117
8118 #[test]
8119 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8120 let temp = TempHome::new();
8121 temp.set_config_json(
8124 &serde_json::json!({
8125 "connect": {
8126 "platforms": [
8127 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8128 ]
8129 }
8130 })
8131 .to_string(),
8132 );
8133 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8134
8135 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8136 assert!(
8137 config.connect.platforms.is_empty(),
8138 "corrupt connect.json must not fall back to the legacy config.json copy"
8139 );
8140 }
8141
8142 #[test]
8143 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8144 let temp = TempHome::new();
8145 let config = Config::create_default();
8146 assert!(config.connect.platforms.is_empty());
8147
8148 config
8149 .save_to_dir(temp.path.clone())
8150 .expect("save succeeds");
8151
8152 assert!(
8153 !connect_json_path(&temp).exists(),
8154 "an empty connect config with no pre-existing file must not create one"
8155 );
8156 }
8157
8158 #[test]
8159 fn connect_json_backed_up_before_overwrite() {
8160 let temp = TempHome::new();
8161 std::fs::write(
8162 connect_json_path(&temp),
8163 serde_json::json!({
8164 "platforms": [
8165 { "type": "telegram", "token_encrypted": "old-cipher" }
8166 ]
8167 })
8168 .to_string(),
8169 )
8170 .unwrap();
8171
8172 let mut config = Config::create_default();
8173 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8174 config
8175 .save_to_dir(temp.path.clone())
8176 .expect("save succeeds");
8177
8178 let backup = connect_json_path(&temp).with_extension("json.bak");
8179 assert!(
8180 std::fs::read_to_string(backup)
8181 .unwrap()
8182 .contains("old-cipher"),
8183 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8184 );
8185 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8186 assert!(current.contains("new-cipher"));
8187 }
8188
8189 #[test]
8192 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8193 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8194 let temp = TempHome::new();
8195
8196 let mut config = Config::create_default();
8197 config.connect.platforms = vec![ConnectPlatformConfig {
8198 id: None,
8199 project_id: None,
8200 platform_type: "feishu".to_string(),
8201 token: None,
8202 token_encrypted: None,
8203 token_credential_ref: None,
8204 token_configured: false,
8205 app_id: Some("cli_real_app_id".to_string()),
8206 app_secret: Some("plain-app-secret".to_string()),
8207 app_secret_encrypted: None,
8208 app_secret_credential_ref: None,
8209 app_secret_configured: false,
8210 domain: Some("lark".to_string()),
8211 allow_from: vec!["ou_1".to_string()],
8212 admin_from: Vec::new(),
8213 }];
8214
8215 config
8216 .save_to_dir(temp.path.clone())
8217 .expect("save succeeds");
8218
8219 let connect_json: serde_json::Value =
8220 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8221 .unwrap();
8222 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8223 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8224 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8225 assert!(
8226 connect_json["platforms"][0]["app_secret_encrypted"]
8227 .as_str()
8228 .is_some_and(|v| !v.is_empty()),
8229 "app_secret is persisted in its encrypted form in connect.json"
8230 );
8231 assert!(
8232 connect_json["platforms"][0].get("app_secret").is_none(),
8233 "the plaintext app_secret is never persisted (skip_serializing)"
8234 );
8235 }
8236
8237 #[test]
8238 fn load_hydrates_feishu_app_secret_from_encrypted() {
8239 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8240 let temp = TempHome::new();
8241
8242 let mut config = Config::create_default();
8243 config.connect.platforms = vec![ConnectPlatformConfig {
8244 id: None,
8245 project_id: None,
8246 platform_type: "feishu".to_string(),
8247 token: None,
8248 token_encrypted: None,
8249 token_credential_ref: None,
8250 token_configured: false,
8251 app_id: Some("cli_real_app_id".to_string()),
8252 app_secret: Some("plain-app-secret".to_string()),
8253 app_secret_encrypted: None,
8254 app_secret_credential_ref: None,
8255 app_secret_configured: false,
8256 domain: Some("lark".to_string()),
8257 allow_from: vec!["ou_1".to_string()],
8258 admin_from: Vec::new(),
8259 }];
8260 config
8261 .save_to_dir(temp.path.clone())
8262 .expect("save succeeds");
8263
8264 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8265 assert_eq!(reloaded.connect.platforms.len(), 1);
8266 assert_eq!(
8267 reloaded.connect.platforms[0].app_secret.as_deref(),
8268 Some("plain-app-secret"),
8269 "reload hydrates app_secret from app_secret_encrypted"
8270 );
8271 assert_eq!(
8272 reloaded.connect.platforms[0].app_id.as_deref(),
8273 Some("cli_real_app_id")
8274 );
8275 assert_eq!(
8276 reloaded.connect.platforms[0].domain.as_deref(),
8277 Some("lark")
8278 );
8279 }
8280
8281 #[test]
8282 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8283 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8284 let temp = TempHome::new();
8285 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8286 std::fs::write(
8287 connect_json_path(&temp),
8288 serde_json::json!({
8289 "platforms": [
8290 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8291 ]
8292 })
8293 .to_string(),
8294 )
8295 .unwrap();
8296
8297 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8298
8299 assert_eq!(config.connect.platforms.len(), 1);
8300 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8301 assert_eq!(
8302 config.connect.platforms[0].token.as_deref(),
8303 Some("legacy-telegram-secret")
8304 );
8305 assert_eq!(
8306 config.connect.platforms[0].app_id, None,
8307 "a legacy entry with no Feishu fields deserializes them as None"
8308 );
8309 assert_eq!(config.connect.platforms[0].app_secret, None);
8310 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8311 assert_eq!(config.connect.platforms[0].domain, None);
8312 }
8313
8314 #[test]
8315 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8316 let _lock = env_lock_acquire();
8317 let temp_home = TempHome::new();
8318 temp_home.set_config_json(
8319 r#"{
8320 "provider": "openai",
8321 "providers": {
8322 "openai": {
8323 "api_key": "sk-test",
8324 "model": "gpt-4o"
8325 }
8326 }
8327}"#,
8328 );
8329
8330 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8331 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8332
8333 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8334
8335 assert_eq!(
8336 config
8337 .providers
8338 .openai
8339 .as_ref()
8340 .and_then(|c| c.model.as_deref()),
8341 Some("gpt-4o")
8342 );
8343 assert!(
8344 config.http_proxy.is_empty(),
8345 "config should keep http_proxy empty when field is omitted"
8346 );
8347 assert!(
8348 config.https_proxy.is_empty(),
8349 "config should keep https_proxy empty when field is omitted"
8350 );
8351 }
8352
8353 #[test]
8354 fn get_memory_background_model_prefers_memory_specific_override() {
8355 let mut config = Config::default();
8356 config.features.provider_model_ref = false;
8357 config.provider = "openai".to_string();
8358 config.providers.openai = Some(OpenAIConfig {
8359 api_key: "test".to_string(),
8360 api_key_encrypted: None,
8361 credential_ref: None,
8362 base_url: None,
8363 model: Some("gpt-main".to_string()),
8364 fast_model: Some("gpt-fast".to_string()),
8365 vision_model: None,
8366 reasoning_effort: None,
8367 responses_only_models: vec![],
8368 request_overrides: None,
8369 extra: BTreeMap::new(),
8370 api_key_from_env: false,
8371 });
8372 config.memory.0 = Some(MemoryConfig {
8373 background_model: Some("memory-fast".to_string()),
8374 ..MemoryConfig::default()
8375 });
8376
8377 assert_eq!(
8378 config.get_memory_background_model().as_deref(),
8379 Some("memory-fast")
8380 );
8381 }
8382
8383 #[test]
8384 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8385 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8390 api_key: api_key.to_string(),
8391 api_key_encrypted: None,
8392 credential_ref: None,
8393 base_url: None,
8394 model: None,
8395 fast_model: None,
8396 vision_model: None,
8397 reasoning_effort: None,
8398 responses_only_models: vec![],
8399 request_overrides: None,
8400 extra: BTreeMap::new(),
8401 api_key_from_env: from_env,
8402 };
8403
8404 let mut current = Config::default();
8406 current.providers.openai = Some(openai("sk-env", true));
8407 let mut merged = Config::default();
8408 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8410 let got = merged.providers.openai.as_ref().unwrap();
8411 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8412 assert!(got.api_key_from_env, "env flag restored");
8413
8414 let mut merged = Config::default();
8416 merged.providers.openai = Some(openai("sk-explicit", false));
8417 merged.preserve_env_sourced_provider_keys(¤t);
8418 assert_eq!(
8419 merged.providers.openai.as_ref().unwrap().api_key,
8420 "sk-explicit",
8421 "explicit patch key must win"
8422 );
8423
8424 let mut current_plain = Config::default();
8426 current_plain.providers.openai = Some(openai("sk-plain", false));
8427 let mut merged = Config::default();
8428 merged.providers.openai = Some(openai("", false));
8429 merged.preserve_env_sourced_provider_keys(¤t_plain);
8430 assert!(
8431 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8432 "non-env key must not be restored by this path"
8433 );
8434 }
8435
8436 #[test]
8437 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8438 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8442 api_key: api_key.to_string(),
8443 api_key_encrypted: enc.map(str::to_string),
8444 credential_ref: None,
8445 base_url: None,
8446 model: None,
8447 fast_model: None,
8448 vision_model: None,
8449 reasoning_effort: None,
8450 responses_only_models: vec![],
8451 request_overrides: None,
8452 extra: BTreeMap::new(),
8453 api_key_from_env: false,
8454 };
8455
8456 let mut config = Config::default();
8458 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8459 config
8460 .refresh_provider_api_keys_encrypted()
8461 .expect("refresh");
8462 assert_eq!(
8463 config
8464 .providers
8465 .openai
8466 .as_ref()
8467 .unwrap()
8468 .api_key_encrypted
8469 .as_deref(),
8470 Some("preexisting-ciphertext"),
8471 "existing ciphertext must be preserved when plaintext is empty"
8472 );
8473
8474 let mut config = Config::default();
8476 config.providers.openai = Some(openai("", None));
8477 config
8478 .refresh_provider_api_keys_encrypted()
8479 .expect("refresh");
8480 assert!(
8481 config
8482 .providers
8483 .openai
8484 .as_ref()
8485 .unwrap()
8486 .api_key_encrypted
8487 .is_none(),
8488 "no key + no ciphertext should stay None"
8489 );
8490
8491 let mut config = Config::default();
8493 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8494 config
8495 .refresh_provider_api_keys_encrypted()
8496 .expect("refresh");
8497 let enc = config
8498 .providers
8499 .openai
8500 .as_ref()
8501 .unwrap()
8502 .api_key_encrypted
8503 .clone()
8504 .expect("ciphertext present");
8505 assert!(
8506 !enc.is_empty() && enc != "stale-ciphertext",
8507 "plaintext re-encrypted"
8508 );
8509 }
8510
8511 #[test]
8512 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8513 let mut config = Config::default();
8519 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8520 "provider_type": "openai",
8521 "api_key": "sk-instance-live",
8522 }))
8523 .expect("valid instance");
8524 config
8525 .provider_instances
8526 .insert("work".to_string(), instance);
8527
8528 config.refresh_encrypted_secrets().expect("refresh");
8529 assert!(
8530 config.provider_instances["work"]
8531 .api_key_encrypted
8532 .is_some(),
8533 "live config must hold ciphertext after refresh"
8534 );
8535
8536 let value = serde_json::to_value(&config).expect("serialize");
8538 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8539 assert!(
8540 back.provider_instances["work"].api_key.is_empty(),
8541 "plaintext is skip_serializing"
8542 );
8543 back.hydrate_provider_instance_api_keys_from_encrypted();
8544 assert_eq!(
8545 back.provider_instances["work"].api_key, "sk-instance-live",
8546 "key must be recoverable from the round-tripped ciphertext"
8547 );
8548 }
8549
8550 #[test]
8551 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8552 let mut config = Config::default();
8553 config.features.provider_model_ref = false;
8554 config.provider = "openai".to_string();
8555 config.providers.openai = Some(OpenAIConfig {
8556 api_key: "test".to_string(),
8557 api_key_encrypted: None,
8558 credential_ref: None,
8559 base_url: None,
8560 model: Some("gpt-main".to_string()),
8561 fast_model: Some("gpt-fast".to_string()),
8562 vision_model: None,
8563 reasoning_effort: None,
8564 responses_only_models: vec![],
8565 request_overrides: None,
8566 extra: BTreeMap::new(),
8567 api_key_from_env: false,
8568 });
8569
8570 assert_eq!(
8571 config.get_memory_background_model().as_deref(),
8572 Some("gpt-fast")
8573 );
8574 }
8575
8576 #[test]
8577 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8578 let mut config = Config::default();
8579 config.features.provider_model_ref = false;
8580 config.provider = "openai".to_string();
8581 config.providers.openai = Some(OpenAIConfig {
8582 api_key: "sk-stale".to_string(),
8583 model: Some("legacy-main".to_string()),
8584 fast_model: Some("legacy-fast".to_string()),
8585 vision_model: Some("legacy-vision".to_string()),
8586 reasoning_effort: Some(ReasoningEffort::Low),
8587 ..OpenAIConfig::default()
8588 });
8589 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8590 "provider_type": "openai",
8591 "model": "instance-main",
8592 "fast_model": "instance-fast",
8593 "vision_model": "instance-vision",
8594 "reasoning_effort": "high",
8595 "enabled": true
8596 }))
8597 .unwrap();
8598 instance.api_key = "sk-instance".to_string();
8599 config
8600 .provider_instances
8601 .insert("work".to_string(), instance);
8602 config.default_provider_instance = Some("work".to_string());
8603
8604 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8605 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8606 assert_eq!(
8607 config.get_memory_background_model().as_deref(),
8608 Some("instance-fast")
8609 );
8610 assert_eq!(
8611 config.get_task_summary_model().as_deref(),
8612 Some("instance-fast")
8613 );
8614 assert_eq!(
8615 config.get_vision_model().as_deref(),
8616 Some("instance-vision")
8617 );
8618 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8619 }
8620
8621 #[test]
8622 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8623 let mut config = Config::default();
8624 config.features.provider_model_ref = true;
8625 config.provider_instances.insert(
8626 "compatibility-default".to_string(),
8627 serde_json::from_value(serde_json::json!({
8628 "provider_type": "openai",
8629 "reasoning_effort": "low",
8630 "enabled": true
8631 }))
8632 .unwrap(),
8633 );
8634 config.provider_instances.insert(
8635 "chat-provider".to_string(),
8636 serde_json::from_value(serde_json::json!({
8637 "provider_type": "anthropic",
8638 "reasoning_effort": "high",
8639 "enabled": true
8640 }))
8641 .unwrap(),
8642 );
8643 config.default_provider_instance = Some("compatibility-default".to_string());
8644 config.defaults = Some(
8645 serde_json::from_value(serde_json::json!({
8646 "chat": { "provider": "chat-provider", "model": "claude" }
8647 }))
8648 .unwrap(),
8649 );
8650
8651 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8652
8653 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8654 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8655 }
8656
8657 #[test]
8658 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8659 let _provider =
8660 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8661 let _openai_key = crate::test_support::override_runtime_env_var(
8662 "BAMBOO_OPENAI_API_KEY",
8663 Some("sk-runtime-env-instance"),
8664 );
8665 let _anthropic_key =
8666 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8667 let _gemini_key =
8668 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8669
8670 let mut config = Config::default();
8671 *config.providers_mut() = ProviderConfigs::default();
8672 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8673 let mut extra = serde_json::Map::new();
8674 if from_environment {
8675 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8676 }
8677 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8678 extra.insert("enabled".to_string(), serde_json::json!(true));
8679 config.provider_instances.insert(
8680 id.to_string(),
8681 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8682 );
8683 }
8684
8685 config.apply_runtime_env_overrides();
8686
8687 assert_eq!(
8688 config.default_provider_instance.as_deref(),
8689 Some("a-marked"),
8690 "type override selects the lexicographically first enabled instance"
8691 );
8692 assert_eq!(
8693 config.provider_instances["a-marked"].api_key,
8694 "sk-runtime-env-instance"
8695 );
8696 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8697 assert!(
8698 config.providers().openai.is_none(),
8699 "instance-mode env hydration must not recreate a legacy alias"
8700 );
8701
8702 config.refresh_encrypted_secrets().unwrap();
8703 let serialized = serde_json::to_string(&config).unwrap();
8704 assert!(!serialized.contains("sk-runtime-env-instance"));
8705 assert!(
8706 config.provider_instances["a-marked"]
8707 .api_key_encrypted
8708 .is_none(),
8709 "runtime env plaintext must not be encrypted into ordinary config"
8710 );
8711 }
8712
8713 #[test]
8714 fn runtime_provider_override_prefers_exact_instance_id() {
8715 let _provider =
8716 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8717 let _openai_key =
8718 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8719 let _anthropic_key =
8720 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8721 let _gemini_key =
8722 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8723 let mut config = Config::default();
8724 for id in ["work", "personal"] {
8725 config.provider_instances.insert(
8726 id.to_string(),
8727 serde_json::from_value(serde_json::json!({
8728 "provider_type": "openai",
8729 "enabled": true
8730 }))
8731 .unwrap(),
8732 );
8733 }
8734
8735 config.apply_runtime_env_overrides();
8736 assert_eq!(
8737 config.default_provider_instance.as_deref(),
8738 Some("personal")
8739 );
8740 }
8741
8742 #[test]
8743 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
8744 let mut config = Config::default();
8745 config.features.provider_model_ref = false;
8746 config.provider = "openai".to_string();
8747 config.providers.openai = Some(OpenAIConfig {
8748 api_key: "test".to_string(),
8749 api_key_encrypted: None,
8750 credential_ref: None,
8751 base_url: None,
8752 model: Some("gpt-main".to_string()),
8753 fast_model: None,
8754 vision_model: None,
8755 reasoning_effort: None,
8756 responses_only_models: vec![],
8757 request_overrides: None,
8758 extra: BTreeMap::new(),
8759 api_key_from_env: false,
8760 });
8761
8762 assert_eq!(
8763 config.get_memory_background_model().as_deref(),
8764 Some("gpt-main")
8765 );
8766 }
8767
8768 #[test]
8769 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8770 let legacy = serde_json::json!({
8771 "memory": MemoryConfig {
8772 background_model: Some("dream-fast".to_string()),
8773 summary_target_ratio: 0.20,
8774 summary_safe_window_percent: 80,
8775 auto_dream_enabled: true,
8776 auto_dream_interval_secs: 900,
8777 project_prompt_injection: false,
8778 relevant_recall: false,
8779 relevant_recall_rerank: true,
8780 project_first_dream: false,
8781 ledger_agenda_injection: false,
8782 ledger_gardener_enabled: false,
8783 ledger_gardener_interval_secs: 7_200,
8784 ledger_distillation_enabled: false,
8785 dream_refine_mode: true,
8786 gardener_enabled: true,
8787 gardener_interval_secs: 3_600,
8788 gardener_volume_trigger: 40,
8789 gardener_max_splits_per_run: 4,
8790 gardener_min_sections: 7,
8791 dedup_gardener_enabled: true,
8792 dedup_gardener_min_score: 0.7,
8793 dedup_gardener_max_merges_per_run: 3,
8794 memory_active_capacity: 500,
8795 capacity_max_archivals_per_run: 10,
8796 granularity_freshness_gardener_enabled: false,
8797 }
8798 });
8799 let config: Config = serde_json::from_value(legacy).unwrap();
8800
8801 let serialized = serde_json::to_value(&config).expect("config should serialize");
8802 assert!(serialized.get("memory").is_some());
8803 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8804 assert!(round_tripped
8805 .memory()
8806 .as_ref()
8807 .is_some_and(|memory| memory.dream_refine_mode));
8808 let memory = config.memory.as_ref().expect("memory config should exist");
8809 assert!(memory.auto_dream_enabled);
8810 assert!(!memory.project_prompt_injection);
8811 assert!(!memory.relevant_recall);
8812 assert!(memory.relevant_recall_rerank);
8813 assert!(!memory.project_first_dream);
8814 assert!(memory.dream_refine_mode);
8815 assert!(memory.gardener_enabled);
8816 assert_eq!(memory.gardener_interval_secs, 3_600);
8817 assert_eq!(memory.gardener_volume_trigger, 40);
8818 assert_eq!(memory.gardener_max_splits_per_run, 4);
8819 assert_eq!(memory.gardener_min_sections, 7);
8820 assert!(memory.dedup_gardener_enabled);
8821 assert_eq!(memory.dedup_gardener_min_score, 0.7);
8822 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8823 assert_eq!(memory.memory_active_capacity, 500);
8824 assert_eq!(memory.capacity_max_archivals_per_run, 10);
8825 assert!(!memory.granularity_freshness_gardener_enabled);
8826 }
8827
8828 #[test]
8830 fn memory_active_capacity_defaults_off() {
8831 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8832 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8833 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8834 let memory = parsed.memory.as_ref().unwrap();
8835 assert_eq!(memory.memory_active_capacity, 0);
8836 assert_eq!(
8837 memory.capacity_max_archivals_per_run, 50,
8838 "omitted field takes the serde default fn"
8839 );
8840 }
8841
8842 #[test]
8843 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8844 let defaults = MemoryConfig::default();
8845 assert_eq!(defaults.summary_target_ratio, 0.20);
8846 assert_eq!(defaults.summary_safe_window_percent, 80);
8847
8848 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8849 let memory = parsed.memory.as_ref().expect("memory present");
8850 assert_eq!(memory.summary_target_ratio, 0.20);
8851 assert_eq!(memory.summary_safe_window_percent, 80);
8852 }
8853
8854 #[test]
8858 fn memory_maintenance_integrators_default_on() {
8859 let defaults = MemoryConfig::default();
8860 assert!(defaults.auto_dream_enabled);
8861 assert!(defaults.gardener_enabled);
8862 assert!(defaults.dedup_gardener_enabled);
8863 assert_eq!(defaults.gardener_volume_trigger, 25);
8864
8865 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8867 let memory = parsed.memory.as_ref().expect("memory present");
8868 assert!(
8869 memory.auto_dream_enabled,
8870 "auto_dream on when field omitted"
8871 );
8872 assert!(memory.gardener_enabled, "gardener on when field omitted");
8873 assert!(
8874 memory.dedup_gardener_enabled,
8875 "dedup gardener on when field omitted"
8876 );
8877 let opted_out: Config =
8879 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8880 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8881 }
8882
8883 #[test]
8884 fn memory_config_env_overrides_prompt_flags() {
8885 let _lock = env_lock_acquire();
8886 let temp_home = TempHome::new();
8887 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8888 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8889 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8890 let _relevant_recall_rerank =
8891 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8892 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8893
8894 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8895 let memory = config
8896 .memory
8897 .as_ref()
8898 .expect("memory config should be created by env overrides");
8899 assert!(!memory.project_prompt_injection);
8900 assert!(!memory.relevant_recall);
8901 assert!(memory.relevant_recall_rerank);
8902 assert!(!memory.project_first_dream);
8903 }
8904
8905 #[test]
8906 fn provider_api_keys_injected_from_env_and_never_persisted() {
8907 let _lock = env_lock_acquire();
8908 let temp_home = TempHome::new();
8909 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8910 let _anthropic = crate::test_support::override_runtime_env_var(
8911 "BAMBOO_ANTHROPIC_API_KEY",
8912 Some("sk-ant-from-env"),
8913 );
8914 let _openai = crate::test_support::override_runtime_env_var(
8915 "BAMBOO_OPENAI_API_KEY",
8916 Some("sk-oai-from-env"),
8917 );
8918 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8919
8920 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8923 assert_eq!(
8924 config
8925 .providers
8926 .anthropic
8927 .as_ref()
8928 .expect("anthropic created from env")
8929 .api_key,
8930 "sk-ant-from-env"
8931 );
8932 assert_eq!(
8933 config
8934 .providers
8935 .openai
8936 .as_ref()
8937 .expect("openai created from env")
8938 .api_key,
8939 "sk-oai-from-env"
8940 );
8941 assert!(config.providers.gemini.is_none());
8943
8944 config
8950 .save_to_dir(temp_home.path.clone())
8951 .expect("save config");
8952 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8953 .expect("read persisted config.json");
8954 assert!(
8955 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8956 "env key must not be persisted as plaintext"
8957 );
8958 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8959 assert!(
8960 disk_json["providers"]["anthropic"]
8961 .get("api_key_encrypted")
8962 .is_none(),
8963 "env-sourced anthropic key must not be re-encrypted into config.json"
8964 );
8965 assert!(
8966 disk_json["providers"]["openai"]
8967 .get("api_key_encrypted")
8968 .is_none(),
8969 "env-sourced openai key must not be re-encrypted into config.json"
8970 );
8971
8972 drop(_anthropic);
8975 drop(_openai);
8976 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
8977 assert!(reloaded
8978 .providers
8979 .anthropic
8980 .as_ref()
8981 .map(|a| a.api_key.is_empty())
8982 .unwrap_or(true));
8983 }
8984
8985 #[test]
8986 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
8987 let _lock = env_lock_acquire();
8988 let temp_home = TempHome::new();
8989 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8990 let target = temp_home.path.join("workspace-default");
8991 std::fs::create_dir_all(&target).expect("default work area dir should exist");
8992
8993 let mut config = Config::default();
8994 config.default_work_area.replace(DefaultWorkAreaConfig {
8995 path: Some("~/workspace-default".to_string()),
8996 });
8997
8998 assert_eq!(config.get_default_work_area_path(), Some(target));
8999 }
9000
9001 #[test]
9002 fn get_default_work_area_path_returns_none_for_missing_directory() {
9003 let _lock = env_lock_acquire();
9004 let temp_home = TempHome::new();
9005 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9006
9007 let mut config = Config::default();
9008 config.default_work_area.replace(DefaultWorkAreaConfig {
9009 path: Some("~/missing-default-work-area".to_string()),
9010 });
9011
9012 assert!(config.get_default_work_area_path().is_none());
9013 }
9014
9015 #[test]
9016 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9017 let mut config = Config::default();
9018 config.tools.disabled = vec![
9019 " read_file ".to_string(),
9020 "".to_string(),
9021 "read_file".to_string(),
9022 "bash".to_string(),
9023 "default::getCurrentDir".to_string(),
9024 "default::applyPatch".to_string(),
9025 "default::custom_tool".to_string(),
9026 "mcp__alpha__inspect".to_string(),
9027 ];
9028
9029 config.normalize_tool_settings();
9030
9031 assert_eq!(
9032 config.tools.disabled,
9033 vec![
9034 "bash",
9035 "default::applyPatch",
9036 "default::custom_tool",
9037 "default::getCurrentDir",
9038 "mcp__alpha__inspect",
9039 "read_file"
9040 ]
9041 );
9042 }
9043
9044 #[test]
9045 fn config_load_preserves_disabled_references_for_catalog_resolution() {
9046 let _lock = env_lock_acquire();
9047 let temp_home = TempHome::new();
9048 temp_home.set_config_json(
9049 r#"{
9050 "tools": {
9051 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9052 }
9053}"#,
9054 );
9055
9056 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9057 assert_eq!(
9058 config.tools.disabled,
9059 vec!["bash", "default::getCurrentDir", "read_file"]
9060 );
9061 assert!(config.disabled_tool_references().contains("bash"));
9062 assert!(config.disabled_tool_references().contains("read_file"));
9063 assert!(config
9064 .disabled_tool_references()
9065 .contains("default::getCurrentDir"));
9066 assert_eq!(
9067 config.disabled_tool_names(),
9068 BTreeSet::from([
9069 "Bash".to_string(),
9070 "GetCurrentDir".to_string(),
9071 "Read".to_string()
9072 ])
9073 );
9074 }
9075
9076 #[test]
9077 fn normalize_skill_settings_trims_dedupes_and_sorts() {
9078 let mut config = Config::default();
9079 config.skills.disabled = vec![
9080 " pdf ".to_string(),
9081 "".to_string(),
9082 "pdf".to_string(),
9083 "skill-creator".to_string(),
9084 ];
9085
9086 config.normalize_skill_settings();
9087
9088 assert_eq!(
9089 config.skills.disabled,
9090 vec!["pdf".to_string(), "skill-creator".to_string()]
9091 );
9092 }
9093
9094 #[test]
9095 fn config_load_reads_disabled_skills_as_normalized_ids() {
9096 let _lock = env_lock_acquire();
9097 let temp_home = TempHome::new();
9098 temp_home.set_config_json(
9099 r#"{
9100 "skills": {
9101 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9102 }
9103}"#,
9104 );
9105
9106 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9107 assert_eq!(
9108 config.skills.disabled,
9109 vec!["pdf".to_string(), "skill-creator".to_string()]
9110 );
9111 assert!(config.disabled_skill_ids().contains("pdf"));
9112 assert!(config.disabled_skill_ids().contains("skill-creator"));
9113 }
9114
9115 #[test]
9116 fn test_server_config_defaults() {
9117 let _lock = env_lock_acquire();
9118 let temp_home = TempHome::new();
9119
9120 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9121 assert_eq!(config.server.port, 9562);
9122 assert_eq!(config.server.bind, "127.0.0.1");
9123 assert_eq!(config.server.workers, 10);
9124 assert!(config.server.static_dir.is_none());
9125 }
9126
9127 #[test]
9128 fn test_server_addr() {
9129 let mut config = Config::default();
9130 config.server.port = 9000;
9131 config.server.bind = "0.0.0.0".to_string();
9132 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9133 }
9134
9135 #[test]
9136 fn test_env_var_overrides() {
9137 let _lock = env_lock_acquire();
9138 let temp_home = TempHome::new();
9139
9140 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9141 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9142 let _provider =
9143 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9144
9145 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9146 assert_eq!(config.server.port, 9999);
9147 assert_eq!(config.server.bind, "192.168.1.1");
9148 assert_eq!(config.provider, "openai");
9149 }
9150
9151 #[test]
9152 fn test_config_save_and_load() {
9153 let _lock = env_lock_acquire();
9154 let temp_home = TempHome::new();
9155
9156 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9157 config.server.port = 9000;
9158 config.server.bind = "0.0.0.0".to_string();
9159 config.provider = "anthropic".to_string();
9160
9161 config
9163 .save_to_dir(temp_home.path.clone())
9164 .expect("Failed to save config");
9165
9166 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9168
9169 assert_eq!(loaded.server.port, 9000);
9171 assert_eq!(loaded.server.bind, "0.0.0.0");
9172 assert_eq!(loaded.provider, "anthropic");
9173 }
9174
9175 #[test]
9176 fn modular_root_and_public_serde_preserve_legacy_shape() {
9177 let _lock = env_lock_acquire();
9178 let temp_home = TempHome::new();
9179 let input = serde_json::json!({
9180 "http_proxy": "http://proxy.example",
9181 "provider": "openai",
9182 "mcp": { "servers": [] },
9183 "future_extension": { "enabled": true },
9184 "memory": { "background_model": "memory-model" },
9185 "subagents": { "max_concurrent": 7 },
9186 "providers": { "openai": { "model": "chat-model" } }
9187 });
9188
9189 let config: Config = serde_json::from_value(input).unwrap();
9190 let public = serde_json::to_value(&config).unwrap();
9191 assert_eq!(public["http_proxy"], "http://proxy.example");
9192 assert!(public.get("mcp").is_none());
9193 assert!(public.get("mcpServers").is_some());
9194 assert_eq!(public["future_extension"]["enabled"], true);
9195 assert_eq!(public["memory"]["background_model"], "memory-model");
9196 assert_eq!(public["subagents"]["max_concurrent"], 7);
9197 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9198
9199 let round_tripped: Config = serde_json::from_value(public).unwrap();
9200 assert_eq!(
9201 round_tripped
9202 .memory()
9203 .as_ref()
9204 .unwrap()
9205 .background_model
9206 .as_deref(),
9207 Some("memory-model")
9208 );
9209 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9210 assert_eq!(
9211 round_tripped
9212 .providers()
9213 .openai
9214 .as_ref()
9215 .unwrap()
9216 .model
9217 .as_deref(),
9218 Some("chat-model")
9219 );
9220 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9221
9222 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9223 let persisted: Value =
9224 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9225 .unwrap();
9226 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9227 assert!(persisted.get("mcpServers").is_some());
9228 assert_eq!(persisted["future_extension"]["enabled"], true);
9229 assert!(persisted.get("memory").is_none());
9230 assert!(persisted.get("subagents").is_none());
9231 assert!(persisted.get("providers").is_none());
9232 for internal_section_name in [
9233 "network",
9234 "provider_routing",
9235 "model_behavior",
9236 "tooling",
9237 "workspace",
9238 "execution",
9239 "integrations",
9240 "plugin_security",
9241 ] {
9242 assert!(persisted.get(internal_section_name).is_none());
9243 }
9244 }
9245
9246 #[test]
9247 fn config_decrypts_proxy_auth_from_encrypted_field() {
9248 let _lock = env_lock_acquire();
9249 let temp_home = TempHome::new();
9250
9251 let key_guard = crate::encryption::set_test_encryption_key([
9253 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9254 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9255 0x1c, 0x1d, 0x1e, 0x1f,
9256 ]);
9257
9258 let auth = ProxyAuth {
9259 username: "user".to_string(),
9260 password: "pass".to_string(),
9261 };
9262 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9263 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9264
9265 temp_home.set_config_json(&format!(
9266 r#"{{
9267 "http_proxy": "http://proxy.example.com:8080",
9268 "proxy_auth_encrypted": "{encrypted}"
9269}}"#
9270 ));
9271 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9272 let loaded_auth = config
9273 .proxy_auth
9274 .as_ref()
9275 .expect("proxy auth should be hydrated");
9276 assert_eq!(loaded_auth.username, "user");
9277 assert_eq!(loaded_auth.password, "pass");
9278 drop(key_guard);
9279 }
9280
9281 #[test]
9282 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9283 let _lock = env_lock_acquire();
9284 let temp_home = TempHome::new();
9285
9286 let key_guard = crate::encryption::set_test_encryption_key([
9288 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9289 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9290 0x1c, 0x1d, 0x1e, 0x1f,
9291 ]);
9292
9293 let auth = ProxyAuth {
9294 username: "user".to_string(),
9295 password: "pass".to_string(),
9296 };
9297 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9298 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9299
9300 temp_home.set_config_json(&format!(
9302 r#"{{
9303 "http_proxy": "http://proxy.example.com:8080",
9304 "http_proxy_auth_encrypted": "{encrypted}",
9305 "https_proxy_auth_encrypted": "{encrypted}"
9306}}"#
9307 ));
9308
9309 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9310 let loaded_auth = config
9311 .proxy_auth
9312 .as_ref()
9313 .expect("proxy auth should be hydrated");
9314 assert_eq!(loaded_auth.username, "user");
9315 assert_eq!(loaded_auth.password, "pass");
9316 drop(key_guard);
9317 }
9318
9319 #[test]
9320 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9321 let _lock = env_lock_acquire();
9322 let temp_home = TempHome::new();
9323
9324 let key_guard = crate::encryption::set_test_encryption_key([
9326 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9327 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9328 0x1c, 0x1d, 0x1e, 0x1f,
9329 ]);
9330
9331 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9332 config.proxy_auth = Some(ProxyAuth {
9333 username: "user".to_string(),
9334 password: "pass".to_string(),
9335 });
9336 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9337 assert!(error
9338 .to_string()
9339 .contains("isolated credential transaction"));
9340 let path = temp_home.path.join("config.json");
9341 assert!(
9342 !path.exists(),
9343 "a rejected unisolated secret must not create config.json"
9344 );
9345 drop(key_guard);
9346 }
9347
9348 #[test]
9349 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9350 let _lock = env_lock_acquire();
9351 let temp_home = TempHome::new();
9352 let mut config = Config::default();
9353 config.env_vars.push(EnvVarEntry {
9354 name: "TOKEN".to_string(),
9355 value: String::new(),
9356 secret: true,
9357 value_encrypted: None,
9358 credential_ref: None,
9359 configured: true,
9360 description: None,
9361 });
9362 let path = temp_home.path.join("config.json");
9363
9364 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9365 assert!(error
9366 .to_string()
9367 .contains("isolated credential transaction"));
9368 assert!(
9369 !path.exists(),
9370 "a rejected dangling ref must not create config.json"
9371 );
9372
9373 let original = br#"{"preserve":"original"}"#;
9374 std::fs::write(&path, original).unwrap();
9375 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9376 assert_eq!(
9377 std::fs::read(&path).unwrap(),
9378 original,
9379 "a rejected dangling ref must not modify an existing config.json"
9380 );
9381 }
9382
9383 #[test]
9384 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9385 let _lock = env_lock_acquire();
9386 let temp_home = TempHome::new();
9387
9388 let key_guard = crate::encryption::set_test_encryption_key([
9390 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9391 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9392 0x1c, 0x1d, 0x1e, 0x1f,
9393 ]);
9394
9395 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9396 crate::CredentialStore::open(&temp_home.path)
9397 .replace(
9398 reference.clone(),
9399 "sk-test-provider-key",
9400 crate::CredentialSource::User,
9401 0,
9402 )
9403 .unwrap();
9404 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9405 config.provider = "openai".to_string();
9406 config.providers.openai = Some(OpenAIConfig {
9407 api_key: "sk-test-provider-key".to_string(),
9408 api_key_encrypted: None,
9409 credential_ref: Some(reference),
9410 base_url: None,
9411 model: None,
9412 fast_model: None,
9413 vision_model: None,
9414 reasoning_effort: None,
9415 responses_only_models: vec![],
9416 request_overrides: None,
9417 extra: Default::default(),
9418 api_key_from_env: false,
9419 });
9420
9421 config
9422 .save_to_dir(temp_home.path.clone())
9423 .expect("save should persist provider reference");
9424
9425 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9426 .expect("read providers.json");
9427 assert!(
9428 !content.contains("api_key_encrypted"),
9429 "providers.json must not store provider ciphertext"
9430 );
9431 assert!(
9432 !content.contains("\"api_key\""),
9433 "providers.json should not store plaintext provider keys"
9434 );
9435
9436 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9437 let openai = loaded
9438 .providers
9439 .openai
9440 .as_ref()
9441 .expect("openai config should be present");
9442 assert_eq!(openai.api_key, "sk-test-provider-key");
9443
9444 drop(key_guard);
9445 }
9446
9447 #[test]
9448 fn config_save_persists_mcp_servers_in_mainstream_format() {
9449 let _lock = env_lock_acquire();
9450 let temp_home = TempHome::new();
9451
9452 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9453
9454 let mut env = std::collections::HashMap::new();
9455 env.insert("TOKEN".to_string(), "supersecret".to_string());
9456
9457 config.mcp.servers = vec![
9458 bamboo_domain::mcp_config::McpServerConfig {
9459 id: "stdio-secret".to_string(),
9460 name: None,
9461 enabled: true,
9462 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9463 bamboo_domain::mcp_config::StdioConfig {
9464 command: "echo".to_string(),
9465 args: vec![],
9466 cwd: None,
9467 env,
9468 env_encrypted: std::collections::HashMap::new(),
9469 env_credential_refs: std::collections::HashMap::new(),
9470 startup_timeout_ms: 5000,
9471 },
9472 ),
9473 request_timeout_ms: 5000,
9474 healthcheck_interval_ms: 1000,
9475 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9476 allowed_tools: vec![],
9477 denied_tools: vec![],
9478 },
9479 bamboo_domain::mcp_config::McpServerConfig {
9480 id: "http-secret".to_string(),
9481 name: None,
9482 enabled: true,
9483 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9484 bamboo_domain::mcp_config::StreamableHttpConfig {
9485 url: "http://localhost:8080/http".to_string(),
9486 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9487 name: "Authorization".to_string(),
9488 value: "Bearer token123".to_string(),
9489 value_encrypted: None,
9490 credential_ref: None,
9491 }],
9492 connect_timeout_ms: 5000,
9493 },
9494 ),
9495 request_timeout_ms: 5000,
9496 healthcheck_interval_ms: 1000,
9497 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9498 allowed_tools: vec![],
9499 denied_tools: vec![],
9500 },
9501 ];
9502
9503 config
9504 .save_to_dir(temp_home.path.clone())
9505 .expect("save should persist MCP servers");
9506
9507 let content =
9508 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9509 assert!(
9510 content.contains("\"mcpServers\""),
9511 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9512 );
9513 assert!(
9514 content.contains("supersecret"),
9515 "config.json should persist MCP stdio env in mainstream format"
9516 );
9517 assert!(
9518 content.contains("Bearer token123"),
9519 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9520 );
9521 assert!(
9522 !content.contains("\"env_encrypted\""),
9523 "config.json should not persist legacy env_encrypted fields"
9524 );
9525 assert!(
9526 !content.contains("\"value_encrypted\""),
9527 "config.json should not persist legacy value_encrypted fields"
9528 );
9529
9530 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9531 let stdio = loaded
9532 .mcp
9533 .servers
9534 .iter()
9535 .find(|s| s.id == "stdio-secret")
9536 .expect("stdio server should exist");
9537 match &stdio.transport {
9538 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9539 assert_eq!(
9540 stdio.env.get("TOKEN").map(|s| s.as_str()),
9541 Some("supersecret")
9542 );
9543 }
9544 _ => panic!("Expected stdio transport"),
9545 }
9546
9547 let http = loaded
9548 .mcp
9549 .servers
9550 .iter()
9551 .find(|s| s.id == "http-secret")
9552 .expect("http server should exist");
9553 match &http.transport {
9554 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9555 assert_eq!(http.headers[0].value, "Bearer token123");
9556 }
9557 _ => panic!("Expected Streamable HTTP transport"),
9558 }
9559 }
9560
9561 #[test]
9562 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9563 let _lock = env_lock_acquire();
9564 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9565 let temp_home = TempHome::new();
9566 let env_plaintext = "mcp-root-env-plaintext-597";
9567 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9568 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9569 std::fs::write(
9570 temp_home.path.join("config.json"),
9571 serde_json::to_vec_pretty(&serde_json::json!({
9572 "features": {"provider_model_ref": true},
9573 "mcpServers": {
9574 "stdio-root": {
9575 "command": "unused-disabled-command",
9576 "env": {"TOKEN": env_plaintext},
9577 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9578 "env_credential_refs": {
9579 "TOKEN": "mcp.stdio-root.env_TOKEN"
9580 }
9581 },
9582 "http-root": {
9583 "url": "https://example.test/mcp",
9584 "transport_kind": "streamable_http",
9585 "headers": {"Authorization": header_plaintext},
9586 "header_credential_refs": {
9587 "Authorization": "mcp.http-root.header_Authorization"
9588 }
9589 }
9590 }
9591 }))
9592 .unwrap(),
9593 )
9594 .unwrap();
9595 std::fs::write(
9596 temp_home.path.join("mcp.json"),
9597 serde_json::to_vec_pretty(&serde_json::json!({
9598 "schema_version": 1,
9599 "revision": 7,
9600 "data": {
9601 "stdio-root": {
9602 "command": "unused-disabled-command",
9603 "enabled": false,
9604 "env_encrypted": {"TOKEN": legacy_ciphertext},
9605 "request_timeout_ms": 100,
9606 "healthcheck_interval_ms": 100
9607 },
9608 "http-root": {
9609 "url": "https://example.test/mcp",
9610 "transport_kind": "streamable_http",
9611 "enabled": false,
9612 "headers": {"Authorization": header_plaintext},
9613 "request_timeout_ms": 100,
9614 "healthcheck_interval_ms": 100
9615 }
9616 }
9617 }))
9618 .unwrap(),
9619 )
9620 .unwrap();
9621
9622 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9623 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9624 temp_home.path.join("mcp.json"),
9625 1,
9626 )
9627 .load()
9628 .unwrap()
9629 .unwrap();
9630 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9631 config.mcp = stored.data;
9632 config
9633 .hydrate_mcp_credentials_from_store(&temp_home.path)
9634 .unwrap();
9635
9636 let public = serde_json::to_value(&config.mcp).unwrap();
9637 let rendered_public = public.to_string();
9638 assert!(rendered_public.contains(env_plaintext));
9639 assert!(rendered_public.contains(header_plaintext));
9640 let compatible: bamboo_domain::mcp_config::McpConfig =
9641 serde_json::from_value(public).unwrap();
9642 assert_eq!(compatible.servers.len(), 2);
9643
9644 config.features.provider_model_ref = !config.features.provider_model_ref;
9645 config.save_to_dir(temp_home.path.clone()).unwrap();
9646 config.features.provider_model_ref = !config.features.provider_model_ref;
9647 config.save_to_dir(temp_home.path.clone()).unwrap();
9648
9649 for entry in std::fs::read_dir(&temp_home.path)
9650 .unwrap()
9651 .filter_map(Result::ok)
9652 {
9653 let name = entry.file_name().to_string_lossy().to_string();
9654 if name == "credentials.json" || entry.path().is_dir() {
9655 continue;
9656 }
9657 if name == "config.json"
9658 || name == "mcp.json"
9659 || name.contains(".bak")
9660 || name.starts_with("config-credential-migration")
9661 {
9662 let bytes = std::fs::read(entry.path()).unwrap();
9663 let content = String::from_utf8_lossy(&bytes);
9664 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9665 assert!(
9666 !content.contains(header_plaintext),
9667 "secret leaked to {name}"
9668 );
9669 assert!(
9670 !content.contains(&legacy_ciphertext),
9671 "legacy ciphertext leaked to {name}"
9672 );
9673 }
9674 }
9675 }
9676
9677 #[test]
9680 fn env_vars_as_map_includes_only_non_empty_values() {
9681 let mut config = Config::default();
9682 config.env_vars.extend([
9683 EnvVarEntry {
9684 name: "A".to_string(),
9685 value: "val_a".to_string(),
9686 secret: false,
9687 value_encrypted: None,
9688 credential_ref: None,
9689 configured: true,
9690 description: None,
9691 },
9692 EnvVarEntry {
9693 name: "B".to_string(),
9694 value: "".to_string(), secret: true,
9696 value_encrypted: None,
9697 credential_ref: None,
9698 configured: false,
9699 description: None,
9700 },
9701 EnvVarEntry {
9702 name: "C".to_string(),
9703 value: " ".to_string(), secret: false,
9705 value_encrypted: None,
9706 credential_ref: None,
9707 configured: true,
9708 description: None,
9709 },
9710 EnvVarEntry {
9711 name: "D".to_string(),
9712 value: "val_d".to_string(),
9713 secret: true,
9714 value_encrypted: Some("enc".to_string()),
9715 credential_ref: None,
9716 configured: true,
9717 description: Some("desc".to_string()),
9718 },
9719 ]);
9720
9721 let map = config.env_vars_as_map();
9722 assert_eq!(map.len(), 2);
9723 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9724 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9725 assert!(!map.contains_key("B"));
9726 assert!(!map.contains_key("C"));
9727 }
9728
9729 #[test]
9730 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9731 let mut config = Config::default();
9732 config.env_vars.extend([
9733 EnvVarEntry {
9734 name: "PLAIN".to_string(),
9735 value: "visible".to_string(),
9736 secret: false,
9737 value_encrypted: None,
9738 credential_ref: None,
9739 configured: true,
9740 description: None,
9741 },
9742 EnvVarEntry {
9743 name: "SECRET".to_string(),
9744 value: "hidden_value".to_string(),
9745 secret: true,
9746 value_encrypted: Some("enc_data".to_string()),
9747 credential_ref: None,
9748 configured: true,
9749 description: None,
9750 },
9751 ]);
9752
9753 config.sanitize_env_vars_for_disk();
9754
9755 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
9758
9759 #[test]
9760 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9761 let mut config = Config::default();
9762 config.env_vars.extend([
9763 EnvVarEntry {
9764 name: "OPEN".to_string(),
9765 value: "val".to_string(),
9766 secret: false,
9767 value_encrypted: None,
9768 credential_ref: None,
9769 configured: true,
9770 description: None,
9771 },
9772 EnvVarEntry {
9773 name: "HIDDEN".to_string(),
9774 value: "real_secret".to_string(),
9775 secret: true,
9776 value_encrypted: Some("enc".to_string()),
9777 credential_ref: None,
9778 configured: true,
9779 description: None,
9780 },
9781 ]);
9782
9783 config.sanitize_env_vars_for_disk();
9784
9785 assert_eq!(config.env_vars[0].value, "val");
9787 assert_eq!(config.env_vars[1].value, "");
9789 assert!(config.env_vars[1].value_encrypted.is_none());
9790 }
9791
9792 #[test]
9793 fn legacy_env_ciphertext_is_read_but_never_serialized() {
9794 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9795 let mut config: Config = serde_json::from_value(serde_json::json!({
9796 "env_vars": [{
9797 "name": "TOKEN",
9798 "secret": true,
9799 "value_encrypted": ciphertext
9800 }]
9801 }))
9802 .unwrap();
9803 config.hydrate_env_vars_from_encrypted();
9804 assert_eq!(config.env_vars[0].value, "my-secret-token");
9805 let serialized = serde_json::to_value(&config).unwrap();
9806 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9807 }
9808
9809 #[test]
9810 fn configured_env_ref_missing_from_store_fails_closed() {
9811 let dir = tempfile::tempdir().unwrap();
9812 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9813 let mut config = Config::default();
9814 config.env_vars.push(EnvVarEntry {
9815 name: "TOKEN".to_string(),
9816 value: String::new(),
9817 secret: true,
9818 value_encrypted: None,
9819 credential_ref: Some(reference),
9820 configured: true,
9821 description: None,
9822 });
9823 let error = config
9824 .hydrate_env_var_credentials_from_store(dir.path())
9825 .unwrap_err();
9826 assert!(error
9827 .to_string()
9828 .contains("referenced env credential is unavailable"));
9829 assert!(config.env_vars[0].configured);
9830 assert!(config.env_vars[0].value.is_empty());
9831 }
9832
9833 #[test]
9834 fn configured_notification_ref_missing_from_store_fails_closed() {
9835 let dir = tempfile::tempdir().unwrap();
9836 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9837 let mut config = Config::default();
9838 config.notifications.ntfy.credential_ref = Some(reference);
9839 config.notifications.ntfy.configured = true;
9840
9841 let error = config
9842 .hydrate_notification_credentials_from_store(dir.path())
9843 .unwrap_err();
9844
9845 assert!(error
9846 .to_string()
9847 .contains("referenced ntfy credential is unavailable"));
9848 assert!(config.notifications.ntfy.configured);
9849 assert!(config.notifications.ntfy.token.is_none());
9850 }
9851
9852 #[test]
9853 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9854 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9855 let dir = tempfile::tempdir().unwrap();
9856 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9857 crate::CredentialStore::open(dir.path())
9858 .replace(
9859 reference.clone(),
9860 "exclusive-notification-secret",
9861 crate::CredentialSource::User,
9862 0,
9863 )
9864 .unwrap();
9865
9866 let mut config = Config::default();
9867 config.notifications.ntfy.credential_ref = Some(reference.clone());
9868 config.notifications.ntfy.configured = true;
9869 config.proxy_auth_credential_ref = Some(reference.clone());
9870 let error = config
9871 .hydrate_notification_credentials_from_store(dir.path())
9872 .unwrap_err();
9873 let rendered = error.to_string();
9874 assert!(rendered
9875 .contains("notification credential reference is shared by another config consumer"));
9876 assert!(!rendered.contains(reference.as_str()));
9877 assert!(config.notifications.ntfy.token.is_none());
9878
9879 config.proxy_auth_credential_ref = None;
9880 config
9881 .hydrate_notification_credentials_from_store(dir.path())
9882 .unwrap();
9883 assert_eq!(
9884 config.notifications.ntfy.token.as_deref(),
9885 Some("exclusive-notification-secret")
9886 );
9887 }
9888
9889 #[test]
9890 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9891 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9892 let dir = tempfile::tempdir().unwrap();
9893 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9894 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9895 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9896 "notifications": {
9897 "ntfy": { "enabled": true, "token_encrypted": ntfy },
9898 "bark": { "enabled": true, "device_key_encrypted": bark }
9899 }
9900 }))
9901 .unwrap();
9902 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9903 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9904
9905 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9906
9907 assert!(loaded.notifications.ntfy.token.is_none());
9908 assert!(loaded.notifications.bark.device_key.is_none());
9909 assert_eq!(
9910 std::fs::read(dir.path().join("config.json")).unwrap(),
9911 bytes
9912 );
9913 }
9914
9915 #[test]
9916 fn publish_and_current_env_vars_round_trip() {
9917 let _lock = crate::test_support::env_cache_lock_acquire();
9927 let mut config = Config::default();
9928 config.env_vars.extend([EnvVarEntry {
9929 name: "TEST_PUBLISH".to_string(),
9930 value: "pub_value".to_string(),
9931 secret: false,
9932 value_encrypted: None,
9933 credential_ref: None,
9934 configured: true,
9935 description: None,
9936 }]);
9937
9938 config.publish_env_vars();
9939 assert_eq!(
9940 Config::current_env_vars()
9941 .get("TEST_PUBLISH")
9942 .map(String::as_str),
9943 Some("pub_value")
9944 );
9945 }
9946
9947 #[test]
9948 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9949 let mut config = Config::default();
9950 config.subagents.broker = Some(BrokerClientConfig {
9951 endpoint: "ws://127.0.0.1:9600".to_string(),
9952 token: "super-secret-token".to_string(),
9953 token_encrypted: None,
9954 credential_ref: None,
9955 configured: false,
9956 });
9957
9958 config.refresh_broker_token_encrypted().unwrap();
9960 config.sanitize_broker_token_for_disk();
9961 let broker = config.subagents.broker.as_ref().unwrap();
9962 assert!(broker.token.is_empty(), "plaintext cleared for disk");
9963 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9964 assert_ne!(
9965 broker.token_encrypted.as_deref(),
9966 Some("super-secret-token")
9967 );
9968
9969 config.hydrate_broker_token_from_encrypted();
9971 assert_eq!(
9972 config.subagents.broker.as_ref().unwrap().token,
9973 "super-secret-token"
9974 );
9975 }
9976
9977 #[test]
9978 fn broker_token_empty_refresh_preserves_ciphertext() {
9979 let mut config = Config::default();
9981 config.subagents.broker = Some(BrokerClientConfig {
9982 endpoint: "ws://h:9600".to_string(),
9983 token: String::new(),
9984 token_encrypted: Some("existing-cipher".to_string()),
9985 credential_ref: None,
9986 configured: false,
9987 });
9988 config.refresh_broker_token_encrypted().unwrap();
9989 assert_eq!(
9990 config
9991 .subagents
9992 .broker
9993 .as_ref()
9994 .unwrap()
9995 .token_encrypted
9996 .as_deref(),
9997 Some("existing-cipher"),
9998 );
9999 }
10000
10001 #[test]
10002 fn notifications_config_defaults_when_key_missing() {
10003 let config: Config = serde_json::from_str("{}").expect("empty object parses");
10006 assert_eq!(config.notifications, NotificationsConfig::default());
10007 assert_eq!(config.notifications.desktop.enabled, None);
10008 assert!(!config.notifications.ntfy.enabled);
10009 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10010 assert_eq!(config.notifications.ntfy.token, None);
10011 assert!(!config.notifications.bark.enabled);
10012 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10013 assert_eq!(config.notifications.bark.device_key, None);
10014 }
10015
10016 #[test]
10017 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10018 let mut config = Config::default();
10019 config.notifications.ntfy = NtfyChannelConfig {
10020 enabled: true,
10021 base_url: "https://ntfy.sh".to_string(),
10022 topic: "bamboo-alerts".to_string(),
10023 token: Some("tk_super_secret".to_string()),
10024 token_encrypted: None,
10025 credential_ref: None,
10026 configured: false,
10027 };
10028
10029 config.refresh_notifications_encrypted().unwrap();
10032 assert!(config.notifications.ntfy.token_encrypted.is_some());
10033 assert_ne!(
10034 config.notifications.ntfy.token_encrypted.as_deref(),
10035 Some("tk_super_secret")
10036 );
10037
10038 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10040 assert!(
10041 !json.contains("tk_super_secret"),
10042 "plaintext token must never be serialized"
10043 );
10044 assert!(!json.contains("token_encrypted"));
10045
10046 config.notifications.ntfy.token = None;
10048 config.hydrate_notifications_from_encrypted();
10049 assert_eq!(
10050 config.notifications.ntfy.token.as_deref(),
10051 Some("tk_super_secret")
10052 );
10053 }
10054
10055 #[test]
10056 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10057 let mut config = Config::default();
10058 config.notifications.bark = BarkChannelConfig {
10059 enabled: true,
10060 base_url: "https://api.day.app".to_string(),
10061 device_key: Some("dk_super_secret".to_string()),
10062 device_key_encrypted: None,
10063 credential_ref: None,
10064 configured: false,
10065 };
10066
10067 config.refresh_notifications_encrypted().unwrap();
10068 assert!(config.notifications.bark.device_key_encrypted.is_some());
10069 assert_ne!(
10070 config.notifications.bark.device_key_encrypted.as_deref(),
10071 Some("dk_super_secret")
10072 );
10073
10074 let json = serde_json::to_string(&config.notifications.bark).unwrap();
10075 assert!(
10076 !json.contains("dk_super_secret"),
10077 "plaintext device key must never be serialized"
10078 );
10079 assert!(!json.contains("device_key_encrypted"));
10080
10081 config.notifications.bark.device_key = None;
10082 config.hydrate_notifications_from_encrypted();
10083 assert_eq!(
10084 config.notifications.bark.device_key.as_deref(),
10085 Some("dk_super_secret")
10086 );
10087 }
10088
10089 #[test]
10090 fn notification_secrets_empty_refresh_preserves_ciphertext() {
10091 let mut config = Config::default();
10094 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10095 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10096
10097 config.refresh_notifications_encrypted().unwrap();
10098
10099 assert_eq!(
10100 config.notifications.ntfy.token_encrypted.as_deref(),
10101 Some("existing-ntfy-cipher")
10102 );
10103 assert_eq!(
10104 config.notifications.bark.device_key_encrypted.as_deref(),
10105 Some("existing-bark-cipher")
10106 );
10107 }
10108
10109 #[test]
10110 fn hydrate_skips_non_secret_entries() {
10111 let mut config = Config::default();
10112 config.env_vars.extend([EnvVarEntry {
10113 name: "PLAIN".to_string(),
10114 value: "original".to_string(),
10115 secret: false,
10116 value_encrypted: Some("should-be-ignored".to_string()),
10117 credential_ref: None,
10118 configured: true,
10119 description: None,
10120 }]);
10121
10122 config.hydrate_env_vars_from_encrypted();
10123 assert_eq!(config.env_vars[0].value, "original");
10125 }
10126
10127 #[test]
10128 fn default_config_has_empty_env_vars() {
10129 assert!(Config::default().env_vars.is_empty());
10134 }
10135
10136 #[test]
10137 fn serde_round_trip_with_env_vars() {
10138 let mut config = Config::default();
10139 config.env_vars.extend([
10140 EnvVarEntry {
10141 name: "KEY1".to_string(),
10142 value: "val1".to_string(),
10143 secret: false,
10144 value_encrypted: None,
10145 credential_ref: None,
10146 configured: true,
10147 description: Some("First key".to_string()),
10148 },
10149 EnvVarEntry {
10150 name: "KEY2".to_string(),
10151 value: "".to_string(), secret: true,
10153 value_encrypted: Some("enc123".to_string()),
10154 credential_ref: None,
10155 configured: true,
10156 description: None,
10157 },
10158 ]);
10159
10160 let json = serde_json::to_string(&config).unwrap();
10161 let restored: Config = serde_json::from_str(&json).unwrap();
10162
10163 assert_eq!(restored.env_vars.len(), 2);
10164 assert_eq!(restored.env_vars[0].name, "KEY1");
10165 assert_eq!(restored.env_vars[0].value, "val1");
10166 assert!(!restored.env_vars[0].secret);
10167 assert_eq!(restored.env_vars[1].name, "KEY2");
10168 assert!(restored.env_vars[1].secret);
10169 assert!(restored.env_vars[1].value_encrypted.is_none());
10170 }
10171
10172 #[test]
10175 #[allow(clippy::field_reassign_with_default)]
10177 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10178 let mut config = Config::default();
10179 config.provider = "openai".to_string();
10180 config.providers.openai = Some(OpenAIConfig {
10181 api_key: "test".to_string(),
10182 api_key_encrypted: None,
10183 credential_ref: None,
10184 base_url: None,
10185 model: Some("legacy-gpt-4o".to_string()),
10186 fast_model: None,
10187 vision_model: None,
10188 reasoning_effort: None,
10189 responses_only_models: vec![],
10190 request_overrides: None,
10191 extra: Default::default(),
10192 api_key_from_env: false,
10193 });
10194 config.features.provider_model_ref = true;
10195 config.defaults = Some(DefaultsConfig {
10196 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10197 fast: None,
10198 task_summary: None,
10199 vision: None,
10200 memory_background: None,
10201 planning: None,
10202 search: None,
10203 code_review: None,
10204 sub_agent: None,
10205 subagent_models: Default::default(),
10206 });
10207
10208 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10209 }
10210
10211 #[test]
10212 #[allow(clippy::field_reassign_with_default)]
10214 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10215 let mut config = Config::default();
10216 config.provider = "openai".to_string();
10217 config.providers.openai = Some(OpenAIConfig {
10218 api_key: "test".to_string(),
10219 api_key_encrypted: None,
10220 credential_ref: None,
10221 base_url: None,
10222 model: Some("legacy-gpt-4o".to_string()),
10223 fast_model: None,
10224 vision_model: None,
10225 reasoning_effort: None,
10226 responses_only_models: vec![],
10227 request_overrides: None,
10228 extra: Default::default(),
10229 api_key_from_env: false,
10230 });
10231 config.features.provider_model_ref = false;
10232 config.defaults = Some(DefaultsConfig {
10233 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10234 fast: None,
10235 task_summary: None,
10236 vision: None,
10237 memory_background: None,
10238 planning: None,
10239 search: None,
10240 code_review: None,
10241 sub_agent: None,
10242 subagent_models: Default::default(),
10243 });
10244
10245 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10246 }
10247
10248 #[test]
10249 #[allow(clippy::field_reassign_with_default)]
10251 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10252 let mut config = Config::default();
10253 config.provider = "openai".to_string();
10254 config.providers.openai = Some(OpenAIConfig {
10255 api_key: "test".to_string(),
10256 api_key_encrypted: None,
10257 credential_ref: None,
10258 base_url: None,
10259 model: Some("gpt-4o".to_string()),
10260 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10261 vision_model: None,
10262 reasoning_effort: None,
10263 responses_only_models: vec![],
10264 request_overrides: None,
10265 extra: Default::default(),
10266 api_key_from_env: false,
10267 });
10268 config.features.provider_model_ref = true;
10269 config.defaults = Some(DefaultsConfig {
10270 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10271 fast: Some(bamboo_domain::ProviderModelRef::new(
10272 "anthropic",
10273 "claude-3-5-haiku",
10274 )),
10275 task_summary: None,
10276 vision: None,
10277 memory_background: None,
10278 planning: None,
10279 search: None,
10280 code_review: None,
10281 sub_agent: None,
10282 subagent_models: Default::default(),
10283 });
10284
10285 assert_eq!(
10286 config.get_fast_model(),
10287 Some("claude-3-5-haiku".to_string())
10288 );
10289 }
10290
10291 #[test]
10292 #[allow(clippy::field_reassign_with_default)]
10294 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10295 let mut config = Config::default();
10296 config.provider = "openai".to_string();
10297 config.providers.openai = Some(OpenAIConfig {
10298 api_key: "test".to_string(),
10299 api_key_encrypted: None,
10300 credential_ref: None,
10301 base_url: None,
10302 model: Some("gpt-4o".to_string()),
10303 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10304 vision_model: None,
10305 reasoning_effort: None,
10306 responses_only_models: vec![],
10307 request_overrides: None,
10308 extra: Default::default(),
10309 api_key_from_env: false,
10310 });
10311 config.features.provider_model_ref = false;
10312 config.defaults = Some(DefaultsConfig {
10313 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10314 fast: Some(bamboo_domain::ProviderModelRef::new(
10315 "anthropic",
10316 "claude-3-5-haiku",
10317 )),
10318 task_summary: None,
10319 vision: None,
10320 memory_background: None,
10321 planning: None,
10322 search: None,
10323 code_review: None,
10324 sub_agent: None,
10325 subagent_models: Default::default(),
10326 });
10327
10328 assert_eq!(
10329 config.get_fast_model(),
10330 Some("legacy-gpt-4o-mini".to_string())
10331 );
10332 }
10333
10334 #[test]
10335 #[allow(clippy::field_reassign_with_default)]
10337 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10338 let mut config = Config::default();
10339 config.provider = "openai".to_string();
10340 config.features.provider_model_ref = true;
10341 config.defaults = Some(DefaultsConfig {
10342 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10343 fast: None,
10344 task_summary: None,
10345 vision: None,
10346 memory_background: None,
10347 planning: None,
10348 search: None,
10349 code_review: None,
10350 sub_agent: None,
10351 subagent_models: Default::default(),
10352 });
10353
10354 assert_eq!(
10355 config.get_fast_model(),
10356 Some("claude-3-7-sonnet".to_string())
10357 );
10358 }
10359
10360 #[test]
10361 #[allow(clippy::field_reassign_with_default)]
10363 fn get_memory_background_model_prefers_defaults_memory_background() {
10364 let mut config = Config::default();
10365 config.provider = "openai".to_string();
10366 config.providers.openai = Some(OpenAIConfig {
10367 api_key: "test".to_string(),
10368 api_key_encrypted: None,
10369 credential_ref: None,
10370 base_url: None,
10371 model: Some("gpt-4o".to_string()),
10372 fast_model: Some("gpt-4o-mini".to_string()),
10373 vision_model: None,
10374 reasoning_effort: None,
10375 responses_only_models: vec![],
10376 request_overrides: None,
10377 extra: Default::default(),
10378 api_key_from_env: false,
10379 });
10380 config.features.provider_model_ref = true;
10381 config.defaults = Some(DefaultsConfig {
10382 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10383 fast: Some(bamboo_domain::ProviderModelRef::new(
10384 "openai",
10385 "gpt-4o-mini",
10386 )),
10387 task_summary: None,
10388 vision: None,
10389 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10390 "anthropic",
10391 "claude-3-5-haiku",
10392 )),
10393 planning: None,
10394 search: None,
10395 code_review: None,
10396 sub_agent: None,
10397 subagent_models: Default::default(),
10398 });
10399
10400 assert_eq!(
10401 config.get_memory_background_model(),
10402 Some("claude-3-5-haiku".to_string())
10403 );
10404 }
10405
10406 #[test]
10407 #[allow(clippy::field_reassign_with_default)]
10409 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10410 let mut config = Config::default();
10411 config.provider = "openai".to_string();
10412 config.features.provider_model_ref = true;
10413 config.defaults = Some(DefaultsConfig {
10414 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10415 fast: Some(bamboo_domain::ProviderModelRef::new(
10416 "anthropic",
10417 "claude-3-5-haiku",
10418 )),
10419 task_summary: None,
10420 vision: None,
10421 memory_background: None,
10422 planning: None,
10423 search: None,
10424 code_review: None,
10425 sub_agent: None,
10426 subagent_models: Default::default(),
10427 });
10428
10429 assert_eq!(
10430 config.get_memory_background_model(),
10431 Some("claude-3-5-haiku".to_string())
10432 );
10433 }
10434
10435 #[test]
10436 #[allow(clippy::field_reassign_with_default)]
10438 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10439 let mut config = Config::default();
10440 config.provider = "openai".to_string();
10441 config.providers.openai = Some(OpenAIConfig {
10442 api_key: "test".to_string(),
10443 api_key_encrypted: None,
10444 credential_ref: None,
10445 base_url: None,
10446 model: Some("gpt-4o".to_string()),
10447 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10448 vision_model: None,
10449 reasoning_effort: None,
10450 responses_only_models: vec![],
10451 request_overrides: None,
10452 extra: Default::default(),
10453 api_key_from_env: false,
10454 });
10455 config.features.provider_model_ref = false;
10456 config.defaults = Some(DefaultsConfig {
10457 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10458 fast: Some(bamboo_domain::ProviderModelRef::new(
10459 "anthropic",
10460 "claude-3-5-haiku",
10461 )),
10462 task_summary: None,
10463 vision: None,
10464 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10465 "anthropic",
10466 "claude-3-5-haiku",
10467 )),
10468 planning: None,
10469 search: None,
10470 code_review: None,
10471 sub_agent: None,
10472 subagent_models: Default::default(),
10473 });
10474
10475 assert_eq!(
10476 config.get_memory_background_model(),
10477 Some("legacy-gpt-4o-mini".to_string())
10478 );
10479 }
10480
10481 #[test]
10488 fn is_host_trusted_requires_https_scheme() {
10489 let hosts = vec!["github.com/bigduu/".to_string()];
10490 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10491 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10492 }
10493
10494 #[test]
10495 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10496 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10498 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10499 let hosts = vec!["github.com/bigduu/".to_string()];
10501 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10502 }
10503
10504 #[test]
10505 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10506 let hosts = vec!["trusted.example.com".to_string()];
10507 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10508 assert!(!is_host_trusted(
10512 "https://trusted.example.com.evil.com/x",
10513 &hosts
10514 ));
10515 assert!(!is_host_trusted(
10516 "https://trusted.example.comevil.com/x",
10517 &hosts
10518 ));
10519 }
10520
10521 #[test]
10522 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10523 let hosts = vec!["github.com/bigduu/".to_string()];
10525 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10526 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10527 }
10528
10529 #[test]
10530 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10531 let hosts = vec!["example.com".to_string()];
10532 assert!(is_host_trusted("https://example.com/", &hosts));
10533 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10534 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10537 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10538 }
10539
10540 #[test]
10541 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10542 let hosts = vec!["github.com/bigduu/".to_string()];
10543 assert!(is_host_trusted(
10545 "https://someuser@github.com/bigduu/x",
10546 &hosts
10547 ));
10548 assert!(!is_host_trusted(
10551 "https://github.com@evil.com/bigduu/",
10552 &hosts
10553 ));
10554 }
10555
10556 #[test]
10557 fn is_host_trusted_ignores_an_explicit_port() {
10558 let hosts = vec!["github.com/bigduu/".to_string()];
10559 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10560 }
10561
10562 #[test]
10563 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10564 let hosts = vec!["github.com/bigduu/".to_string()];
10565 assert!(!is_host_trusted("not a url at all", &hosts));
10566 assert!(!is_host_trusted("", &hosts));
10567 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10569
10570 #[test]
10571 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10572 let hosts = vec!["github.com/bigduu/".to_string()];
10573 assert!(!is_host_trusted(
10576 "https://github.com/bigduu/../evil/x",
10577 &hosts
10578 ));
10579 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10581 }
10582
10583 #[test]
10584 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10585 let mut config = Config::default();
10586 config.plugin_trust.trusted_hosts = vec![
10587 " GitHub.com/BigDuu/ ".to_string(),
10588 "".to_string(),
10589 " ".to_string(),
10590 "Example.COM".to_string(),
10591 ];
10592 config.normalize_plugin_trust_settings();
10593 assert_eq!(
10594 config.plugin_trust.trusted_hosts,
10595 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10596 );
10597 }
10598
10599 #[test]
10605 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10606 let config = Config::default();
10608 assert_eq!(
10609 config.plugin_trust.enforcement,
10610 PluginTrustEnforcement::Strict
10611 );
10612 assert!(!config.plugin_trust.enforcement_is_off());
10613
10614 let json = serde_json::json!({
10620 "trusted_hosts": ["example.com"],
10621 "trusted_keys": [],
10622 });
10623 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10624 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10625 }
10626
10627 #[test]
10628 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10629 for raw in ["off", "OFF", "Off", " off "] {
10630 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10631 "enforcement": raw,
10632 }))
10633 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10634 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10635 assert!(trust.enforcement_is_off());
10636 }
10637 for raw in ["strict", "STRICT", " Strict "] {
10638 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10639 "enforcement": raw,
10640 }))
10641 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10642 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10643 }
10644
10645 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10646 "enforcement": "nonsense",
10647 }))
10648 .expect_err("an unrecognized string must be rejected, not silently default");
10649 assert!(err.to_string().contains("nonsense"));
10650 }
10651
10652 #[test]
10653 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10654 let trust: PluginTrustConfig =
10657 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10658 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10659
10660 let trust: PluginTrustConfig =
10661 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10662 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10663 }
10664
10665 #[test]
10666 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10667 let trust = PluginTrustConfig {
10672 enforcement: PluginTrustEnforcement::Off,
10673 ..PluginTrustConfig::default()
10674 };
10675 let json = serde_json::to_value(&trust).unwrap();
10676 assert_eq!(json["enforcement"], "off");
10677
10678 let trust = PluginTrustConfig {
10679 enforcement: PluginTrustEnforcement::Strict,
10680 ..PluginTrustConfig::default()
10681 };
10682 let json = serde_json::to_value(&trust).unwrap();
10683 assert_eq!(json["enforcement"], "strict");
10684 }
10685
10686 #[test]
10687 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10688 let mut config = Config::default();
10691 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10692 config.normalize_plugin_trust_settings();
10693 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10694 }
10695
10696 #[test]
10697 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10698 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10703 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10704 )));
10705 assert_eq!(
10706 config.plugin_trust.enforcement,
10707 PluginTrustEnforcement::Strict
10708 );
10709
10710 let outcome = crate::dot_path::apply_dot_path_set(
10711 &config,
10712 "plugin_trust.enforcement",
10713 crate::dot_path::parse_cli_value("off"),
10714 )
10715 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10716 assert_eq!(
10717 outcome.config.plugin_trust.enforcement,
10718 PluginTrustEnforcement::Off
10719 );
10720
10721 let outcome = crate::dot_path::apply_dot_path_set(
10723 &outcome.config,
10724 "plugin_trust.enforcement",
10725 crate::dot_path::parse_cli_value("strict"),
10726 )
10727 .expect("setting it back to strict should also round-trip");
10728 assert_eq!(
10729 outcome.config.plugin_trust.enforcement,
10730 PluginTrustEnforcement::Strict
10731 );
10732 }
10733}
10734
10735#[cfg(test)]
10736mod final_evidence_feature_tests {
10737 use super::FeatureFlags;
10738
10739 #[test]
10740 fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
10741 let existing: FeatureFlags =
10742 serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
10743 assert!(!existing.final_evidence_check);
10744 let enabled: FeatureFlags =
10745 serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
10746 let restored: FeatureFlags =
10747 serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
10748 assert!(restored.final_evidence_check);
10749 assert!(!FeatureFlags::default().final_evidence_check);
10750 }
10751}