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 WorktreeSettings {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub default_root: Option<String>,
226 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
227 pub project_roots: BTreeMap<String, String>,
228}
229
230impl WorktreeSettings {
231 pub fn is_empty(&self) -> bool {
232 self.default_root.is_none() && self.project_roots.is_empty()
233 }
234}
235
236#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
238pub struct AccessControlConfig {
239 #[serde(default)]
241 pub password_enabled: bool,
242 #[serde(default)]
247 pub repair_required: bool,
248 #[serde(default, skip_serializing)]
252 pub password_hash: Option<String>,
253 #[serde(default, skip_serializing)]
255 pub password_salt: Option<String>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub password_credential_ref: Option<crate::CredentialRef>,
259 #[serde(default)]
261 pub password_configured: bool,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub updated_at: Option<String>,
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub devices: Vec<DeviceCredential>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279pub struct DeviceCredential {
280 pub device_id: String,
282 pub label: String,
284 #[serde(default, skip_serializing)]
286 pub token_hash: String,
287 #[serde(default, skip_serializing)]
289 pub token_salt: String,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub token_credential_ref: Option<crate::CredentialRef>,
293 #[serde(default)]
295 pub token_configured: bool,
296 pub created_at: String,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub last_used_at: Option<String>,
301 #[serde(default)]
304 pub revoked: bool,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
310pub struct MemoryConfig {
311 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub background_model: Option<String>,
315 #[serde(default = "default_summary_target_ratio")]
319 pub summary_target_ratio: f64,
320 #[serde(default = "default_summary_safe_window_percent")]
324 pub summary_safe_window_percent: u8,
325 #[serde(default = "default_true_auto_dream_enabled")]
330 pub auto_dream_enabled: bool,
331 #[serde(default = "default_auto_dream_interval_secs")]
335 pub auto_dream_interval_secs: u64,
336 #[serde(
338 default = "default_true_memory_project_prompt_injection",
339 alias = "memory_project_prompt_injection"
340 )]
341 pub project_prompt_injection: bool,
342 #[serde(
344 default = "default_true_memory_relevant_recall",
345 alias = "memory_relevant_recall"
346 )]
347 pub relevant_recall: bool,
348 #[serde(default, alias = "memory_relevant_recall_rerank")]
351 pub relevant_recall_rerank: bool,
352 #[serde(
354 default = "default_true_memory_project_first_dream",
355 alias = "memory_project_first_dream"
356 )]
357 pub project_first_dream: bool,
358 #[serde(
362 default = "default_true_memory_ledger_agenda",
363 alias = "memory_ledger_agenda_injection"
364 )]
365 pub ledger_agenda_injection: bool,
366 #[serde(default = "default_true_ledger_gardener_enabled")]
371 pub ledger_gardener_enabled: bool,
372 #[serde(default = "default_ledger_gardener_interval_secs")]
374 pub ledger_gardener_interval_secs: u64,
375 #[serde(default = "default_true_ledger_distillation_enabled")]
378 pub ledger_distillation_enabled: bool,
379 #[serde(default, alias = "memory_dream_refine_mode")]
386 pub dream_refine_mode: bool,
387 #[serde(
393 default = "default_true_gardener_enabled",
394 alias = "memory_gardener_enabled"
395 )]
396 pub gardener_enabled: bool,
397 #[serde(default = "default_gardener_interval_secs")]
400 pub gardener_interval_secs: u64,
401 #[serde(default = "default_gardener_volume_trigger")]
406 pub gardener_volume_trigger: usize,
407 #[serde(default = "default_gardener_max_splits_per_run")]
409 pub gardener_max_splits_per_run: usize,
410 #[serde(default = "default_gardener_min_sections")]
412 pub gardener_min_sections: usize,
413 #[serde(
418 default = "default_true_dedup_gardener_enabled",
419 alias = "memory_dedup_gardener_enabled"
420 )]
421 pub dedup_gardener_enabled: bool,
422 #[serde(default = "default_dedup_gardener_min_score")]
425 pub dedup_gardener_min_score: f64,
426 #[serde(default = "default_dedup_gardener_max_merges_per_run")]
428 pub dedup_gardener_max_merges_per_run: usize,
429 #[serde(default)]
437 pub memory_active_capacity: usize,
438 #[serde(default = "default_capacity_max_archivals_per_run")]
441 pub capacity_max_archivals_per_run: usize,
442 #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
450 pub granularity_freshness_gardener_enabled: bool,
451}
452
453impl Default for MemoryConfig {
454 fn default() -> Self {
455 Self {
456 background_model: None,
457 summary_target_ratio: default_summary_target_ratio(),
458 summary_safe_window_percent: default_summary_safe_window_percent(),
459 auto_dream_enabled: default_true_auto_dream_enabled(),
460 auto_dream_interval_secs: default_auto_dream_interval_secs(),
461 project_prompt_injection: default_true_memory_project_prompt_injection(),
462 relevant_recall: default_true_memory_relevant_recall(),
463 relevant_recall_rerank: false,
464 project_first_dream: default_true_memory_project_first_dream(),
465 ledger_agenda_injection: default_true_memory_ledger_agenda(),
466 ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
467 ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
468 ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
469 dream_refine_mode: false,
470 gardener_enabled: default_true_gardener_enabled(),
471 gardener_interval_secs: default_gardener_interval_secs(),
472 gardener_volume_trigger: default_gardener_volume_trigger(),
473 gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
474 gardener_min_sections: default_gardener_min_sections(),
475 dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
476 dedup_gardener_min_score: default_dedup_gardener_min_score(),
477 dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
478 memory_active_capacity: 0,
479 capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
480 granularity_freshness_gardener_enabled:
481 default_true_granularity_freshness_gardener_enabled(),
482 }
483 }
484}
485
486fn default_summary_target_ratio() -> f64 {
487 0.20
488}
489
490fn default_summary_safe_window_percent() -> u8 {
491 80
492}
493
494fn default_true_granularity_freshness_gardener_enabled() -> bool {
495 true
496}
497
498fn default_capacity_max_archivals_per_run() -> usize {
499 50
500}
501
502fn default_true_auto_dream_enabled() -> bool {
503 true
504}
505
506fn default_true_gardener_enabled() -> bool {
507 true
508}
509
510fn default_true_dedup_gardener_enabled() -> bool {
511 true
512}
513
514fn default_true_memory_ledger_agenda() -> bool {
515 true
516}
517
518fn default_true_ledger_gardener_enabled() -> bool {
519 true
520}
521
522fn default_ledger_gardener_interval_secs() -> u64 {
523 21_600
524}
525
526fn default_true_ledger_distillation_enabled() -> bool {
527 true
528}
529
530fn default_gardener_volume_trigger() -> usize {
534 25
535}
536
537fn default_gardener_interval_secs() -> u64 {
538 86_400
539}
540
541fn default_auto_dream_interval_secs() -> u64 {
542 60 * 30
543}
544
545fn default_gardener_max_splits_per_run() -> usize {
546 8
547}
548
549fn default_gardener_min_sections() -> usize {
550 5
551}
552
553fn default_dedup_gardener_min_score() -> f64 {
554 0.6
555}
556
557fn default_dedup_gardener_max_merges_per_run() -> usize {
558 8
559}
560
561fn default_true_memory_project_prompt_injection() -> bool {
562 true
563}
564
565fn default_true_memory_relevant_recall() -> bool {
566 true
567}
568
569fn default_true_memory_project_first_dream() -> bool {
570 true
571}
572
573#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
591pub struct RunBudgetConfig {
592 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub max_total_tokens: Option<u64>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub max_tool_calls: Option<u32>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub max_subagents: Option<u32>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub max_rounds: Option<u32>,
615}
616
617fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
620 match (config_default, request) {
621 (Some(a), Some(b)) => Some(a.min(b)),
622 (Some(a), None) => Some(a),
623 (None, Some(b)) => Some(b),
624 (None, None) => None,
625 }
626}
627
628impl RunBudgetConfig {
629 pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
644 let Some(over) = request_override else {
645 return *self;
646 };
647 Self {
648 max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
649 max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
650 max_subagents: min_limit(self.max_subagents, over.max_subagents),
651 max_rounds: min_limit(self.max_rounds, over.max_rounds),
652 }
653 }
654}
655
656#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(rename_all = "snake_case")]
663pub enum CodexAuthMode {
664 Inherit,
666 ApiKey,
668 Custom,
670 #[default]
672 Bamboo,
673}
674
675#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "snake_case")]
678pub enum CodexWireApi {
679 #[default]
680 Responses,
681}
682
683#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(rename_all = "snake_case")]
686pub enum CodexMode {
687 #[default]
689 Exec,
690 AppServer,
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "kebab-case")]
697pub enum CodexSandbox {
698 ReadOnly,
699 WorkspaceWrite,
700 DangerFullAccess,
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
706#[serde(rename_all = "kebab-case")]
707pub enum CodexApprovalPolicy {
708 Never,
709 OnFailure,
710 OnRequest,
711}
712
713pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
715
716#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
727pub struct SubagentsConfig {
728 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub max_spawn_depth: Option<u32>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub max_concurrent: Option<usize>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub worker_bin: Option<String>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub worker_args: Option<Vec<String>>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub fabric_dir: Option<String>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub executor: Option<String>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub claude_code_binary: Option<String>,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub claude_code_model: Option<String>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub claude_code_permission_mode: Option<String>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub claude_code_inherit_user_config: Option<bool>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub claude_code_forward_env: Option<Vec<String>>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub codex_binary: Option<String>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub codex_model: Option<String>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub codex_mode: Option<CodexMode>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub codex_auth_mode: Option<CodexAuthMode>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub codex_base_url: Option<String>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub codex_wire_api: Option<CodexWireApi>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub codex_provider_key_ref: Option<crate::CredentialRef>,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub codex_forward_env: Option<Vec<String>>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
812 pub codex_sandbox: Option<CodexSandbox>,
813 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub codex_approval_policy: Option<CodexApprovalPolicy>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub codex_network_access: Option<bool>,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub codex_allow_danger_bypass: Option<bool>,
824 #[serde(skip)]
831 pub broker: Option<BrokerClientConfig>,
832 #[serde(default, skip_serializing_if = "Vec::is_empty")]
838 pub remote_placements: Vec<RemoteActorPlacement>,
839 #[serde(default, skip_serializing_if = "Vec::is_empty")]
852 pub schedulable_placements: Vec<SchedulablePlacement>,
853 #[serde(default, skip_serializing_if = "Vec::is_empty")]
872 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
873}
874
875impl SubagentsConfig {
876 pub fn effective_max_spawn_depth(&self) -> u32 {
877 self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
878 }
879}
880
881#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
884pub struct McpRoleAllowlistEntry {
885 pub role: String,
893 #[serde(default)]
901 pub tools: Vec<String>,
902}
903
904#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(deny_unknown_fields)]
911pub struct OperatorPlacementRequirements {
912 pub trust_zone: String,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub workspace_label: Option<String>,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub network_zone: Option<String>,
917 #[serde(default)]
918 pub require_network_isolation: bool,
919 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
922 pub required_tools: std::collections::BTreeSet<String>,
923}
924
925#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
928#[serde(deny_unknown_fields)]
929pub struct SchedulableBrokerParent {
930 pub parent_mailbox: String,
931 pub parent_role: String,
932}
933
934#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
938pub struct SchedulablePlacement {
939 pub role: String,
942 pub pool: String,
944 #[serde(default, skip_serializing_if = "String::is_empty")]
946 pub registry_url: String,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub token_env: Option<String>,
952 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub ca_cert_file: Option<String>,
955 #[serde(default, skip_serializing_if = "Option::is_none")]
959 pub broker_parent: Option<SchedulableBrokerParent>,
960 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub placement_requirements: Option<OperatorPlacementRequirements>,
962}
963
964#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
975pub struct RemoteActorPlacement {
976 pub role: String,
979 pub endpoint: String,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub token_env: Option<String>,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub ca_cert_file: Option<String>,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub broker_peer: Option<RemoteBrokerPeer>,
994 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub placement_requirements: Option<OperatorPlacementRequirements>,
996}
997
998#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1000#[serde(deny_unknown_fields)]
1001pub struct RemoteBrokerPeer {
1002 pub parent_mailbox: String,
1003 pub worker_mailbox: String,
1004 pub parent_role: Option<String>,
1005 pub worker_role: Option<String>,
1006}
1007impl RemoteBrokerPeer {
1008 pub fn valid(&self) -> bool {
1009 fn identifier(s: &str, mailbox: bool) -> bool {
1010 !s.is_empty()
1011 && s.len() <= 256
1012 && s != "."
1013 && s != ".."
1014 && s.bytes()
1015 .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1016 && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1017 }
1018 identifier(&self.parent_mailbox, true)
1019 && identifier(&self.worker_mailbox, true)
1020 && self.parent_mailbox != self.worker_mailbox
1021 && self
1022 .parent_role
1023 .as_deref()
1024 .is_none_or(|s| identifier(s, false))
1025 && self
1026 .worker_role
1027 .as_deref()
1028 .is_some_and(|s| identifier(s, false))
1029 }
1030}
1031
1032#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1034pub struct BrokerClientConfig {
1035 pub endpoint: String,
1037 #[serde(default, skip_serializing)]
1043 pub token: String,
1044 #[serde(default, skip_serializing)]
1046 pub token_encrypted: Option<String>,
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub credential_ref: Option<crate::CredentialRef>,
1050 #[serde(default)]
1053 pub configured: bool,
1054}
1055
1056impl std::fmt::Debug for BrokerClientConfig {
1057 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1058 formatter
1059 .debug_struct("BrokerClientConfig")
1060 .field("endpoint", &self.endpoint)
1061 .field("credential_ref", &self.credential_ref)
1062 .field("configured", &self.configured)
1063 .finish_non_exhaustive()
1064 }
1065}
1066
1067#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1069pub struct DesktopChannelConfig {
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub enabled: Option<bool>,
1078}
1079
1080#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1082pub struct NtfyChannelConfig {
1083 #[serde(default)]
1084 pub enabled: bool,
1085 #[serde(default = "default_ntfy_base_url")]
1087 pub base_url: String,
1088 #[serde(default)]
1091 pub topic: String,
1092 #[serde(default, skip_serializing)]
1098 pub token: Option<String>,
1099 #[serde(default, skip_serializing)]
1101 pub token_encrypted: Option<String>,
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
1104 pub credential_ref: Option<crate::CredentialRef>,
1105 #[serde(default)]
1107 pub configured: bool,
1108}
1109
1110impl Default for NtfyChannelConfig {
1111 fn default() -> Self {
1112 Self {
1113 enabled: false,
1114 base_url: default_ntfy_base_url(),
1115 topic: String::new(),
1116 token: None,
1117 token_encrypted: None,
1118 credential_ref: None,
1119 configured: false,
1120 }
1121 }
1122}
1123
1124fn default_ntfy_base_url() -> String {
1125 "https://ntfy.sh".to_string()
1126}
1127
1128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1130pub struct BarkChannelConfig {
1131 #[serde(default)]
1132 pub enabled: bool,
1133 #[serde(default = "default_bark_base_url")]
1135 pub base_url: String,
1136 #[serde(default, skip_serializing)]
1141 pub device_key: Option<String>,
1142 #[serde(default, skip_serializing)]
1144 pub device_key_encrypted: Option<String>,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub credential_ref: Option<crate::CredentialRef>,
1148 #[serde(default)]
1150 pub configured: bool,
1151}
1152
1153impl Default for BarkChannelConfig {
1154 fn default() -> Self {
1155 Self {
1156 enabled: false,
1157 base_url: default_bark_base_url(),
1158 device_key: None,
1159 device_key_encrypted: None,
1160 credential_ref: None,
1161 configured: false,
1162 }
1163 }
1164}
1165
1166fn default_bark_base_url() -> String {
1167 "https://api.day.app".to_string()
1168}
1169
1170#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1175pub struct NotificationsConfig {
1176 #[serde(default)]
1177 pub desktop: DesktopChannelConfig,
1178 #[serde(default)]
1179 pub ntfy: NtfyChannelConfig,
1180 #[serde(default)]
1181 pub bark: BarkChannelConfig,
1182}
1183
1184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1188pub struct ConnectPlatformConfig {
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub id: Option<String>,
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub project_id: Option<bamboo_domain::ProjectId>,
1209 #[serde(rename = "type")]
1213 pub platform_type: String,
1214 #[serde(default, skip_serializing)]
1220 pub token: Option<String>,
1221 #[serde(default, skip_serializing_if = "Option::is_none")]
1223 pub token_encrypted: Option<String>,
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1226 pub token_credential_ref: Option<crate::CredentialRef>,
1227 #[serde(default)]
1229 pub token_configured: bool,
1230 #[serde(default, skip_serializing_if = "Option::is_none")]
1233 pub app_id: Option<String>,
1234 #[serde(default, skip_serializing)]
1240 pub app_secret: Option<String>,
1241 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub app_secret_encrypted: Option<String>,
1244 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub app_secret_credential_ref: Option<crate::CredentialRef>,
1247 #[serde(default)]
1249 pub app_secret_configured: bool,
1250 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub domain: Option<String>,
1257 #[serde(default)]
1262 pub allow_from: Vec<String>,
1263 #[serde(default)]
1267 pub admin_from: Vec<String>,
1268}
1269
1270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1274pub struct ConnectConfig {
1275 #[serde(default)]
1276 pub platforms: Vec<ConnectPlatformConfig>,
1277}
1278
1279fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1280 config.platforms.is_empty()
1281}
1282
1283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1290pub struct TrustedKey {
1291 pub label: String,
1293 pub algorithm: String,
1295 pub public_key: String,
1297}
1298
1299pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1301 "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1302
1303fn default_trusted_keys() -> Vec<TrustedKey> {
1310 vec![
1311 TrustedKey {
1312 label: "jiandu (bigduu official)".to_string(),
1313 algorithm: "ed25519".to_string(),
1314 public_key: JIANDU_PLUGIN_PUBLIC_KEY.to_string(),
1315 },
1316 TrustedKey {
1317 label: "nova (bigduu official)".to_string(),
1318 algorithm: "ed25519".to_string(),
1319 public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1320 .to_string(),
1321 },
1322 TrustedKey {
1323 label: "magpie (bigduu official)".to_string(),
1324 algorithm: "ed25519".to_string(),
1325 public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1326 .to_string(),
1327 },
1328 ]
1329}
1330
1331fn default_trusted_hosts() -> Vec<String> {
1334 vec!["github.com/bigduu/".to_string()]
1335}
1336
1337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1354pub struct PluginTrustConfig {
1355 #[serde(default = "default_trusted_hosts")]
1366 pub trusted_hosts: Vec<String>,
1367 #[serde(default = "default_trusted_keys")]
1370 pub trusted_keys: Vec<TrustedKey>,
1371 #[serde(default)]
1385 pub enforcement: PluginTrustEnforcement,
1386}
1387
1388impl Default for PluginTrustConfig {
1389 fn default() -> Self {
1390 Self {
1391 trusted_hosts: default_trusted_hosts(),
1392 trusted_keys: default_trusted_keys(),
1393 enforcement: PluginTrustEnforcement::default(),
1394 }
1395 }
1396}
1397
1398impl PluginTrustConfig {
1399 pub fn is_host_trusted(&self, url: &str) -> bool {
1405 is_host_trusted(url, &self.trusted_hosts)
1406 }
1407
1408 pub fn enforcement_is_off(&self) -> bool {
1414 matches!(self.enforcement, PluginTrustEnforcement::Off)
1415 }
1416}
1417
1418#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1431#[serde(rename_all = "snake_case")]
1432pub enum PluginTrustEnforcement {
1433 #[default]
1438 Strict,
1439 Off,
1443}
1444
1445impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1446 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1447 where
1448 D: serde::Deserializer<'de>,
1449 {
1450 #[derive(Deserialize)]
1451 #[serde(untagged)]
1452 enum Repr {
1453 Bool(bool),
1454 Str(String),
1455 }
1456 match Repr::deserialize(deserializer)? {
1457 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1458 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1459 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1460 "strict" => Ok(PluginTrustEnforcement::Strict),
1461 "off" => Ok(PluginTrustEnforcement::Off),
1462 other => Err(serde::de::Error::custom(format!(
1463 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1464 \"off\""
1465 ))),
1466 },
1467 }
1468 }
1469}
1470
1471struct TrustedHostEntry<'a> {
1476 host: &'a str,
1477 path_prefix: &'a str,
1478}
1479
1480fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1484 match entry.find('/') {
1485 Some(index) => TrustedHostEntry {
1486 host: &entry[..index],
1487 path_prefix: &entry[index..],
1488 },
1489 None => TrustedHostEntry {
1490 host: entry,
1491 path_prefix: "",
1492 },
1493 }
1494}
1495
1496fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1508 if prefix.is_empty() || path == prefix {
1509 return true;
1510 }
1511 if prefix.ends_with('/') {
1512 return path.starts_with(prefix);
1513 }
1514 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1515}
1516
1517pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1541 let Ok(parsed) = url::Url::parse(url) else {
1542 return false;
1543 };
1544 if parsed.scheme() != "https" {
1545 return false;
1546 }
1547 let Some(host) = parsed.host_str() else {
1548 return false;
1549 };
1550 let host = host.to_ascii_lowercase();
1551 let path = parsed.path();
1552
1553 trusted_hosts.iter().any(|raw_entry| {
1554 let entry = raw_entry.trim().to_ascii_lowercase();
1555 let parsed_entry = parse_trusted_host_entry(&entry);
1556 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1557 })
1558}
1559
1560#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1569#[serde(rename_all = "snake_case")]
1570pub enum ContextManagementStrategy {
1571 #[default]
1572 Summary,
1573 MessageSummary,
1574 RetrievalWindow,
1575}
1576
1577#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1580#[serde(rename_all = "snake_case")]
1581pub enum ContextManagementFallbackStrategy {
1582 #[default]
1583 None,
1584 Summary,
1585}
1586
1587fn default_retrieval_window_min_recent_user_turns() -> usize {
1588 3
1589}
1590
1591fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1592 0.80
1593}
1594
1595fn default_retrieval_window_target_usage_ratio() -> f64 {
1596 0.60
1597}
1598
1599fn default_history_tool_required() -> bool {
1600 true
1601}
1602
1603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1605#[serde(default)]
1606pub struct RetrievalWindowContextConfig {
1607 #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1608 pub min_recent_user_turns: usize,
1609 #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1610 pub trigger_usage_ratio: f64,
1611 #[serde(default = "default_retrieval_window_target_usage_ratio")]
1612 pub target_usage_ratio: f64,
1613 #[serde(default = "default_history_tool_required")]
1614 pub history_tool_required: bool,
1615 #[serde(default)]
1616 pub fallback_strategy: ContextManagementFallbackStrategy,
1617}
1618
1619impl Default for RetrievalWindowContextConfig {
1620 fn default() -> Self {
1621 Self {
1622 min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1623 trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1624 target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1625 history_tool_required: default_history_tool_required(),
1626 fallback_strategy: ContextManagementFallbackStrategy::None,
1627 }
1628 }
1629}
1630
1631#[derive(Debug, Clone, Serialize, PartialEq)]
1633pub struct ContextManagementConfig {
1634 pub strategy: ContextManagementStrategy,
1635 pub retrieval_window: RetrievalWindowContextConfig,
1636}
1637
1638impl Default for ContextManagementConfig {
1639 fn default() -> Self {
1640 Self {
1641 strategy: ContextManagementStrategy::Summary,
1642 retrieval_window: RetrievalWindowContextConfig::default(),
1643 }
1644 }
1645}
1646
1647impl ContextManagementConfig {
1648 pub fn is_default(&self) -> bool {
1649 *self == Self::default()
1650 }
1651
1652 pub fn validate(&self) -> std::result::Result<(), String> {
1653 let policy = &self.retrieval_window;
1654 if policy.min_recent_user_turns == 0 {
1655 return Err(
1656 "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1657 .to_string(),
1658 );
1659 }
1660 if !policy.target_usage_ratio.is_finite()
1661 || !policy.trigger_usage_ratio.is_finite()
1662 || policy.target_usage_ratio < 0.01
1663 || policy.target_usage_ratio >= policy.trigger_usage_ratio
1664 || policy.trigger_usage_ratio > 1.0
1665 {
1666 return Err(
1667 "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1668 .to_string(),
1669 );
1670 }
1671 if self.strategy == ContextManagementStrategy::RetrievalWindow
1672 && !policy.history_tool_required
1673 {
1674 return Err(
1675 "retrieval_window requires history_tool_required=true in this release".to_string(),
1676 );
1677 }
1678 Ok(())
1679 }
1680
1681 pub fn retrieval_target_usage_percent(&self) -> u8 {
1686 ((self.retrieval_window.target_usage_ratio * 100.0).floor() as u8).max(1)
1687 }
1688}
1689
1690impl<'de> Deserialize<'de> for ContextManagementConfig {
1691 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1692 where
1693 D: serde::Deserializer<'de>,
1694 {
1695 #[derive(Deserialize)]
1696 #[serde(default)]
1697 struct Wire {
1698 strategy: ContextManagementStrategy,
1699 retrieval_window: RetrievalWindowContextConfig,
1700 }
1701
1702 impl Default for Wire {
1703 fn default() -> Self {
1704 let defaults = ContextManagementConfig::default();
1705 Self {
1706 strategy: defaults.strategy,
1707 retrieval_window: defaults.retrieval_window,
1708 }
1709 }
1710 }
1711
1712 let wire = Wire::deserialize(deserializer)?;
1713 let config = Self {
1714 strategy: wire.strategy,
1715 retrieval_window: wire.retrieval_window,
1716 };
1717 config.validate().map_err(serde::de::Error::custom)?;
1718 Ok(config)
1719 }
1720}
1721
1722#[derive(Debug, Clone, Serialize, Deserialize)]
1727#[doc(hidden)]
1728pub struct ConfigValues {
1729 #[serde(default)]
1731 pub http_proxy: String,
1732 #[serde(default)]
1734 pub https_proxy: String,
1735 #[serde(skip_serializing)]
1739 pub proxy_auth: Option<ProxyAuth>,
1740 #[serde(default, skip_serializing_if = "Option::is_none")]
1742 pub proxy_auth_encrypted: Option<String>,
1743 #[serde(default, skip_serializing_if = "Option::is_none")]
1745 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1746 #[serde(default)]
1748 pub headless_auth: bool,
1749
1750 #[serde(default = "default_provider")]
1752 pub provider: String,
1753
1754 #[serde(default, skip_serializing_if = "Option::is_none")]
1756 pub defaults: Option<DefaultsConfig>,
1757
1758 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1765 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1766
1767 #[serde(default, skip_serializing_if = "Option::is_none")]
1771 pub default_provider_instance: Option<String>,
1772
1773 #[serde(default)]
1775 pub server: ServerConfig,
1776
1777 #[serde(default)]
1781 pub keyword_masking: KeywordMaskingConfig,
1782
1783 #[serde(default)]
1787 pub anthropic_model_mapping: AnthropicModelMapping,
1788
1789 #[serde(default)]
1793 pub gemini_model_mapping: GeminiModelMapping,
1794
1795 #[serde(default)]
1800 pub hooks: HooksConfig,
1801
1802 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1808 pub lifecycle_hooks: LifecycleHooksConfig,
1809
1810 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1814 pub tools: ToolsConfig,
1815
1816 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1821 pub skills: SkillsConfig,
1822
1823 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1827 pub env_vars: Vec<EnvVarEntry>,
1828
1829 #[serde(default, skip_serializing_if = "Option::is_none")]
1831 pub default_work_area: Option<DefaultWorkAreaConfig>,
1832
1833 #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1834 pub worktrees: WorktreeSettings,
1835
1836 #[serde(default, skip_serializing_if = "Option::is_none")]
1838 pub access_control: Option<AccessControlConfig>,
1839
1840 #[serde(default)]
1842 pub features: FeatureFlags,
1843
1844 #[serde(default)]
1849 pub run_budget: RunBudgetConfig,
1850
1851 #[serde(default)]
1853 pub stream_timeout: StreamTimeoutConfig,
1854
1855 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1857 pub context_management: ContextManagementConfig,
1858
1859 #[serde(
1863 default,
1864 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1865 )]
1866 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1867
1868 #[serde(default, rename = "mcpServers", alias = "mcp")]
1874 pub mcp: bamboo_domain::mcp_config::McpConfig,
1875
1876 #[serde(default)]
1880 pub notifications: NotificationsConfig,
1881
1882 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1887 pub connect: ConnectConfig,
1888
1889 #[serde(default)]
1893 pub plugin_trust: PluginTrustConfig,
1894
1895 #[serde(default, flatten)]
1901 pub extra: BTreeMap<String, Value>,
1902}
1903
1904impl Default for ConfigValues {
1905 fn default() -> Self {
1906 Self {
1907 http_proxy: String::new(),
1908 https_proxy: String::new(),
1909 proxy_auth: None,
1910 proxy_auth_encrypted: None,
1911 proxy_auth_credential_ref: None,
1912 headless_auth: false,
1913 run_budget: RunBudgetConfig::default(),
1914 stream_timeout: StreamTimeoutConfig::default(),
1915 context_management: ContextManagementConfig::default(),
1916 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1917 provider: default_provider(),
1918 provider_instances: HashMap::new(),
1919 default_provider_instance: None,
1920 server: ServerConfig::default(),
1921 keyword_masking: KeywordMaskingConfig::default(),
1922 anthropic_model_mapping: AnthropicModelMapping::default(),
1923 gemini_model_mapping: GeminiModelMapping::default(),
1924 hooks: HooksConfig::default(),
1925 lifecycle_hooks: LifecycleHooksConfig::default(),
1926 tools: ToolsConfig::default(),
1927 skills: SkillsConfig::default(),
1928 env_vars: Vec::new(),
1929 default_work_area: None,
1930 worktrees: WorktreeSettings::default(),
1931 access_control: None,
1932 features: FeatureFlags::default(),
1933 defaults: None,
1934 mcp: bamboo_domain::mcp_config::McpConfig::default(),
1935 notifications: NotificationsConfig::default(),
1936 connect: ConnectConfig::default(),
1937 plugin_trust: PluginTrustConfig::default(),
1938 extra: BTreeMap::new(),
1939 }
1940 }
1941}
1942
1943#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1946struct NetworkConfigSection {
1947 #[serde(default)]
1948 http_proxy: String,
1949 #[serde(default)]
1950 https_proxy: String,
1951 #[serde(skip_serializing)]
1952 proxy_auth: Option<ProxyAuth>,
1953 #[serde(default, skip_serializing_if = "Option::is_none")]
1954 proxy_auth_encrypted: Option<String>,
1955 #[serde(default, skip_serializing_if = "Option::is_none")]
1956 proxy_auth_credential_ref: Option<crate::CredentialRef>,
1957 #[serde(default)]
1958 headless_auth: bool,
1959 #[serde(default)]
1960 server: ServerConfig,
1961}
1962
1963#[derive(Debug, Clone, Serialize, Deserialize)]
1964struct ProviderRoutingConfigSection {
1965 #[serde(default = "default_provider")]
1966 provider: String,
1967 #[serde(default, skip_serializing_if = "Option::is_none")]
1968 defaults: Option<DefaultsConfig>,
1969 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1970 provider_instances: HashMap<String, ProviderInstanceConfig>,
1971 #[serde(default, skip_serializing_if = "Option::is_none")]
1972 default_provider_instance: Option<String>,
1973}
1974
1975impl Default for ProviderRoutingConfigSection {
1976 fn default() -> Self {
1977 Self {
1978 provider: default_provider(),
1979 defaults: None,
1980 provider_instances: HashMap::new(),
1981 default_provider_instance: None,
1982 }
1983 }
1984}
1985
1986#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1987struct ModelBehaviorConfigSection {
1988 #[serde(default)]
1989 keyword_masking: KeywordMaskingConfig,
1990 #[serde(default)]
1991 anthropic_model_mapping: AnthropicModelMapping,
1992 #[serde(default)]
1993 gemini_model_mapping: GeminiModelMapping,
1994 #[serde(default)]
1995 hooks: HooksConfig,
1996 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1997 lifecycle_hooks: LifecycleHooksConfig,
1998}
1999
2000#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2001struct ToolingConfigSection {
2002 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2003 tools: ToolsConfig,
2004 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2005 skills: SkillsConfig,
2006 #[serde(default, rename = "mcpServers", alias = "mcp")]
2007 mcp: bamboo_domain::mcp_config::McpConfig,
2008}
2009
2010#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2011struct WorkspaceConfigSection {
2012 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2013 env_vars: Vec<EnvVarEntry>,
2014 #[serde(default, skip_serializing_if = "Option::is_none")]
2015 default_work_area: Option<DefaultWorkAreaConfig>,
2016 #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2017 worktrees: WorktreeSettings,
2018 #[serde(default, skip_serializing_if = "Option::is_none")]
2019 access_control: Option<AccessControlConfig>,
2020}
2021
2022#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2023struct ExecutionConfigSection {
2024 #[serde(default)]
2025 features: FeatureFlags,
2026 #[serde(default)]
2027 run_budget: RunBudgetConfig,
2028 #[serde(default)]
2029 stream_timeout: StreamTimeoutConfig,
2030 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2031 context_management: ContextManagementConfig,
2032 #[serde(
2033 default,
2034 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2035 )]
2036 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2037}
2038
2039#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2040struct IntegrationConfigSection {
2041 #[serde(default)]
2042 notifications: NotificationsConfig,
2043 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2044 connect: ConnectConfig,
2045}
2046
2047#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2048struct PluginSecurityConfigSection {
2049 #[serde(default)]
2050 plugin_trust: PluginTrustConfig,
2051}
2052
2053macro_rules! count_fields {
2056 ($($field:ident),* $(,)?) => {
2057 <[()]>::len(&[$(count_fields!(@one $field)),*])
2058 };
2059 (@one $field:ident) => { () };
2060}
2061
2062macro_rules! define_counted_struct {
2063 (
2064 $(#[$struct_meta:meta])*
2065 $visibility:vis struct $name:ident {
2066 $(
2067 $(#[$field_meta:meta])*
2068 $field_visibility:vis $field:ident: $field_type:ty
2069 ),* $(,)?
2070 }
2071 count $count_visibility:vis $count_name:ident;
2072 ) => {
2073 $(#[$struct_meta])*
2074 $visibility struct $name {
2075 $(
2076 $(#[$field_meta])*
2077 $field_visibility $field: $field_type,
2078 )*
2079 }
2080
2081 $count_visibility const $count_name: usize = count_fields!($($field),*);
2082 };
2083}
2084
2085define_counted_struct! {
2086 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2092 struct ConfigRoot {
2093 #[serde(flatten)]
2094 network: NetworkConfigSection,
2095 #[serde(flatten)]
2096 provider_routing: ProviderRoutingConfigSection,
2097 #[serde(flatten)]
2098 model_behavior: ModelBehaviorConfigSection,
2099 #[serde(flatten)]
2100 tooling: ToolingConfigSection,
2101 #[serde(flatten)]
2102 workspace: WorkspaceConfigSection,
2103 #[serde(flatten)]
2104 execution: ExecutionConfigSection,
2105 #[serde(flatten)]
2106 integrations: IntegrationConfigSection,
2107 #[serde(flatten)]
2108 plugin_security: PluginSecurityConfigSection,
2109 #[serde(default, flatten)]
2110 extra: BTreeMap<String, Value>,
2111 }
2112 count pub PERSISTED_ROOT_FIELD_COUNT;
2113}
2114
2115impl From<ConfigValues> for ConfigRoot {
2116 fn from(values: ConfigValues) -> Self {
2117 let ConfigValues {
2120 http_proxy,
2121 https_proxy,
2122 proxy_auth,
2123 proxy_auth_encrypted,
2124 proxy_auth_credential_ref,
2125 headless_auth,
2126 provider,
2127 defaults,
2128 provider_instances,
2129 default_provider_instance,
2130 server,
2131 keyword_masking,
2132 anthropic_model_mapping,
2133 gemini_model_mapping,
2134 hooks,
2135 lifecycle_hooks,
2136 tools,
2137 skills,
2138 env_vars,
2139 default_work_area,
2140 worktrees,
2141 access_control,
2142 features,
2143 run_budget,
2144 stream_timeout,
2145 context_management,
2146 cluster_fabric,
2147 mcp,
2148 notifications,
2149 connect,
2150 plugin_trust,
2151 extra,
2152 } = values;
2153
2154 Self {
2155 network: NetworkConfigSection {
2156 http_proxy,
2157 https_proxy,
2158 proxy_auth,
2159 proxy_auth_encrypted,
2160 proxy_auth_credential_ref,
2161 headless_auth,
2162 server,
2163 },
2164 provider_routing: ProviderRoutingConfigSection {
2165 provider,
2166 defaults,
2167 provider_instances,
2168 default_provider_instance,
2169 },
2170 model_behavior: ModelBehaviorConfigSection {
2171 keyword_masking,
2172 anthropic_model_mapping,
2173 gemini_model_mapping,
2174 hooks,
2175 lifecycle_hooks,
2176 },
2177 tooling: ToolingConfigSection { tools, skills, mcp },
2178 workspace: WorkspaceConfigSection {
2179 env_vars,
2180 default_work_area,
2181 worktrees,
2182 access_control,
2183 },
2184 execution: ExecutionConfigSection {
2185 features,
2186 run_budget,
2187 stream_timeout,
2188 context_management,
2189 cluster_fabric,
2190 },
2191 integrations: IntegrationConfigSection {
2192 notifications,
2193 connect,
2194 },
2195 plugin_security: PluginSecurityConfigSection { plugin_trust },
2196 extra,
2197 }
2198 }
2199}
2200
2201impl From<ConfigRoot> for ConfigValues {
2202 fn from(root: ConfigRoot) -> Self {
2203 let ConfigRoot {
2206 network,
2207 provider_routing,
2208 model_behavior,
2209 tooling,
2210 workspace,
2211 execution,
2212 integrations,
2213 plugin_security,
2214 extra,
2215 } = root;
2216 let NetworkConfigSection {
2217 http_proxy,
2218 https_proxy,
2219 proxy_auth,
2220 proxy_auth_encrypted,
2221 proxy_auth_credential_ref,
2222 headless_auth,
2223 server,
2224 } = network;
2225 let ProviderRoutingConfigSection {
2226 provider,
2227 defaults,
2228 provider_instances,
2229 default_provider_instance,
2230 } = provider_routing;
2231 let ModelBehaviorConfigSection {
2232 keyword_masking,
2233 anthropic_model_mapping,
2234 gemini_model_mapping,
2235 hooks,
2236 lifecycle_hooks,
2237 } = model_behavior;
2238 let ToolingConfigSection { tools, skills, mcp } = tooling;
2239 let WorkspaceConfigSection {
2240 env_vars,
2241 default_work_area,
2242 worktrees,
2243 access_control,
2244 } = workspace;
2245 let ExecutionConfigSection {
2246 features,
2247 run_budget,
2248 stream_timeout,
2249 context_management,
2250 cluster_fabric,
2251 } = execution;
2252 let IntegrationConfigSection {
2253 notifications,
2254 connect,
2255 } = integrations;
2256 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2257
2258 Self {
2259 http_proxy,
2260 https_proxy,
2261 proxy_auth,
2262 proxy_auth_encrypted,
2263 proxy_auth_credential_ref,
2264 headless_auth,
2265 provider,
2266 defaults,
2267 provider_instances,
2268 default_provider_instance,
2269 server,
2270 keyword_masking,
2271 anthropic_model_mapping,
2272 gemini_model_mapping,
2273 hooks,
2274 lifecycle_hooks,
2275 tools,
2276 skills,
2277 env_vars,
2278 default_work_area,
2279 worktrees,
2280 access_control,
2281 features,
2282 run_budget,
2283 stream_timeout,
2284 context_management,
2285 cluster_fabric,
2286 mcp,
2287 notifications,
2288 connect,
2289 plugin_trust,
2290 extra,
2291 }
2292 }
2293}
2294
2295fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2302 let instance_native = values
2303 .default_provider_instance
2304 .as_ref()
2305 .is_some_and(|id| values.provider_instances.contains_key(id));
2306 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2307 if instance_native {
2308 if let Some(object) = value.as_object_mut() {
2309 object.remove("provider");
2310 object.remove("providers");
2311 }
2312 }
2313 Ok(value)
2314}
2315
2316define_counted_struct! {
2317 #[derive(Debug, Clone)]
2320 pub struct Config {
2321 values: ConfigValues,
2322 pub(crate) memory: crate::MemoryConfigModule,
2323 pub(crate) subagents: crate::SubagentsConfigModule,
2324 pub(crate) providers: crate::ProviderConfigsModule,
2325 recovery_status: Option<ConfigRecoveryStatus>,
2326 }
2327 count pub CONFIG_FIELD_COUNT;
2328}
2329
2330pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2332const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2333const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2334
2335impl std::ops::Deref for Config {
2336 type Target = ConfigValues;
2337
2338 fn deref(&self) -> &Self::Target {
2339 &self.values
2340 }
2341}
2342
2343impl std::ops::DerefMut for Config {
2344 fn deref_mut(&mut self) -> &mut Self::Target {
2345 &mut self.values
2346 }
2347}
2348
2349impl Serialize for Config {
2350 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2351 where
2352 S: serde::Serializer,
2353 {
2354 self.to_compatibility_value()
2355 .map_err(serde::ser::Error::custom)?
2356 .serialize(serializer)
2357 }
2358}
2359
2360impl<'de> Deserialize<'de> for Config {
2361 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2362 where
2363 D: serde::Deserializer<'de>,
2364 {
2365 use serde::de::Error;
2366
2367 let mut value = Value::deserialize(deserializer)?;
2368 let object = value
2369 .as_object_mut()
2370 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2371 let memory = object
2372 .remove("memory")
2373 .map(serde_json::from_value)
2374 .transpose()
2375 .map_err(D::Error::custom)?
2376 .unwrap_or_default();
2377 let subagents = object
2378 .remove("subagents")
2379 .map(serde_json::from_value)
2380 .transpose()
2381 .map_err(D::Error::custom)?
2382 .unwrap_or_default();
2383 let providers = object
2384 .remove("providers")
2385 .map(serde_json::from_value)
2386 .transpose()
2387 .map_err(D::Error::custom)?
2388 .unwrap_or_default();
2389 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2390
2391 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2392 }
2393}
2394
2395#[derive(Debug, Clone, PartialEq, Serialize)]
2397#[serde(tag = "kind", rename_all = "snake_case")]
2398pub enum ConfigRecoverySource {
2399 Salvaged { fields: Vec<String> },
2404 Backup { generation: usize },
2407 Defaults,
2409}
2410
2411#[derive(Debug, Clone, PartialEq, Serialize)]
2423pub struct ConfigRecoveryStatus {
2424 pub source: ConfigRecoverySource,
2426 pub quarantine_path: Option<PathBuf>,
2432 pub confirmed: bool,
2435}
2436
2437#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2441pub struct ProviderConfigs {
2442 #[serde(skip_serializing_if = "Option::is_none")]
2444 pub openai: Option<OpenAIConfig>,
2445 #[serde(skip_serializing_if = "Option::is_none")]
2447 pub anthropic: Option<AnthropicConfig>,
2448 #[serde(skip_serializing_if = "Option::is_none")]
2450 pub gemini: Option<GeminiConfig>,
2451 #[serde(skip_serializing_if = "Option::is_none")]
2453 pub copilot: Option<CopilotConfig>,
2454 #[serde(skip_serializing_if = "Option::is_none")]
2456 pub bodhi: Option<BodhiConfig>,
2457
2458 #[serde(default, flatten)]
2460 pub extra: BTreeMap<String, Value>,
2461}
2462
2463impl ProviderConfigs {
2464 pub fn clear_legacy_builtin_aliases(&mut self) {
2467 self.openai = None;
2468 self.anthropic = None;
2469 self.gemini = None;
2470 self.copilot = None;
2471 self.bodhi = None;
2472 self.extra.remove("provider");
2473 }
2474}
2475
2476#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2478pub struct FeatureFlags {
2479 #[serde(default)]
2481 pub ticket_mutation: bool,
2482 #[serde(default)]
2484 pub ticket_dispatch: bool,
2485 #[serde(default)]
2487 pub provider_model_ref: bool,
2488 #[serde(default)]
2490 pub dynamic_model_routing: bool,
2491 #[serde(default)]
2493 pub final_evidence_check: bool,
2494}
2495
2496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2500pub struct DefaultsConfig {
2501 pub chat: bamboo_domain::ProviderModelRef,
2502 #[serde(default, skip_serializing_if = "Option::is_none")]
2503 pub fast: Option<bamboo_domain::ProviderModelRef>,
2504 #[serde(default, skip_serializing_if = "Option::is_none")]
2505 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2506 #[serde(default, skip_serializing_if = "Option::is_none")]
2507 pub vision: Option<bamboo_domain::ProviderModelRef>,
2508 #[serde(default, skip_serializing_if = "Option::is_none")]
2509 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2510 #[serde(default, skip_serializing_if = "Option::is_none")]
2513 pub planning: Option<bamboo_domain::ProviderModelRef>,
2514 #[serde(default, skip_serializing_if = "Option::is_none")]
2517 pub search: Option<bamboo_domain::ProviderModelRef>,
2518 #[serde(default, skip_serializing_if = "Option::is_none")]
2521 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2522 #[serde(
2525 default,
2526 skip_serializing_if = "Option::is_none",
2527 alias = "sub_session"
2528 )]
2529 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2530 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2534 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2535}
2536
2537#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2539pub struct HooksConfig {
2540 #[serde(default)]
2542 pub image_fallback: ImageFallbackHookConfig,
2543}
2544
2545pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2547pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2549pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2553
2554pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2556 "SessionStart",
2557 "UserPromptSubmit",
2558 "PreToolUse",
2559 "PostToolUse",
2560 "Stop",
2561 "SessionEnd",
2562 "PreCompact",
2563 "Notification",
2564];
2565
2566fn default_lifecycle_hook_timeout_ms() -> u64 {
2567 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2568}
2569
2570fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2571 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2572}
2573
2574fn lifecycle_hook_enabled_default() -> bool {
2575 true
2576}
2577
2578fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2579 *value
2580}
2581
2582#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2587pub struct LifecycleHooksConfig {
2588 #[serde(default)]
2589 pub enabled: bool,
2590 #[serde(
2591 default,
2592 rename = "SessionStart",
2593 skip_serializing_if = "Vec::is_empty"
2594 )]
2595 pub session_start: Vec<LifecycleHookGroup>,
2596 #[serde(
2597 default,
2598 rename = "UserPromptSubmit",
2599 skip_serializing_if = "Vec::is_empty"
2600 )]
2601 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2602 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2603 pub pre_tool_use: Vec<LifecycleHookGroup>,
2604 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2605 pub post_tool_use: Vec<LifecycleHookGroup>,
2606 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2607 pub stop: Vec<LifecycleHookGroup>,
2608 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2609 pub session_end: Vec<LifecycleHookGroup>,
2610 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2611 pub pre_compact: Vec<LifecycleHookGroup>,
2612 #[serde(
2613 default,
2614 rename = "Notification",
2615 skip_serializing_if = "Vec::is_empty"
2616 )]
2617 pub notification: Vec<LifecycleHookGroup>,
2618}
2619
2620impl LifecycleHooksConfig {
2621 pub fn is_empty(&self) -> bool {
2622 !self.enabled
2623 && self.session_start.is_empty()
2624 && self.user_prompt_submit.is_empty()
2625 && self.pre_tool_use.is_empty()
2626 && self.post_tool_use.is_empty()
2627 && self.stop.is_empty()
2628 && self.session_end.is_empty()
2629 && self.pre_compact.is_empty()
2630 && self.notification.is_empty()
2631 }
2632}
2633
2634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2636pub struct LifecycleHookGroup {
2637 #[serde(
2640 default = "lifecycle_hook_enabled_default",
2641 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2642 )]
2643 pub enabled: bool,
2644 #[serde(default, skip_serializing_if = "Option::is_none")]
2645 pub matcher: Option<String>,
2646 #[serde(default)]
2647 pub hooks: Vec<LifecycleHookHandler>,
2648}
2649
2650impl Default for LifecycleHookGroup {
2651 fn default() -> Self {
2652 Self {
2653 enabled: true,
2654 matcher: None,
2655 hooks: Vec::new(),
2656 }
2657 }
2658}
2659
2660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2665#[serde(tag = "type", rename_all = "lowercase")]
2666pub enum LifecycleHookHandler {
2667 Command {
2668 command: String,
2669 #[serde(
2670 default = "default_lifecycle_hook_timeout_ms",
2671 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2672 )]
2673 timeout_ms: u64,
2674 },
2675 Script {
2676 path: String,
2677 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2678 runner: LifecycleScriptRunner,
2679 #[serde(
2680 default = "default_lifecycle_hook_timeout_ms",
2681 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2682 )]
2683 timeout_ms: u64,
2684 },
2685}
2686
2687#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2693#[serde(rename_all = "lowercase")]
2694pub enum LifecycleScriptRunner {
2695 #[default]
2696 Auto,
2697 Node,
2698 Bun,
2699 Python,
2700 Bash,
2701 PowerShell,
2702 Cmd,
2703}
2704
2705impl LifecycleScriptRunner {
2706 pub fn is_auto(&self) -> bool {
2707 matches!(self, Self::Auto)
2708 }
2709
2710 pub fn as_str(self) -> &'static str {
2711 match self {
2712 Self::Auto => "auto",
2713 Self::Node => "node",
2714 Self::Bun => "bun",
2715 Self::Python => "python",
2716 Self::Bash => "bash",
2717 Self::PowerShell => "powershell",
2718 Self::Cmd => "cmd",
2719 }
2720 }
2721
2722 pub fn supports_path(self, path: &str) -> bool {
2724 let extension = lifecycle_script_extension(path);
2725 match self {
2726 Self::Auto => extension.is_some(),
2727 Self::Node | Self::Bun => {
2728 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2729 }
2730 Self::Python => matches!(extension.as_deref(), Some("py")),
2731 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2732 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2733 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2734 }
2735 }
2736}
2737
2738pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2741 let extension = std::path::Path::new(path)
2742 .extension()?
2743 .to_str()?
2744 .to_ascii_lowercase();
2745 matches!(
2746 extension.as_str(),
2747 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2748 )
2749 .then_some(extension)
2750}
2751
2752impl LifecycleHookHandler {
2753 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2754 Self::Command {
2755 command: command.into(),
2756 timeout_ms,
2757 }
2758 }
2759
2760 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2761 Self::Script {
2762 path: path.into(),
2763 runner,
2764 timeout_ms,
2765 }
2766 }
2767
2768 pub fn timeout_ms(&self) -> u64 {
2769 match self {
2770 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2771 }
2772 }
2773}
2774
2775#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2782pub struct RequestOverridesConfig {
2783 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2785 pub common: RequestScopeOverride,
2786 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2788 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2789 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2791 pub rules: Vec<ModelRequestRule>,
2792}
2793
2794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2796pub struct ModelRequestRule {
2797 pub model_pattern: String,
2799 #[serde(default, skip_serializing_if = "Option::is_none")]
2801 pub endpoint: Option<String>,
2802 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2804 pub scope: RequestScopeOverride,
2805}
2806
2807#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2809pub struct RequestScopeOverride {
2810 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2812 pub headers: BTreeMap<String, TemplateExpr>,
2813 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2815 pub body_patch: Vec<BodyPatch>,
2816}
2817
2818impl RequestScopeOverride {
2819 pub fn is_empty(&self) -> bool {
2820 self.headers.is_empty() && self.body_patch.is_empty()
2821 }
2822}
2823
2824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2826pub struct BodyPatch {
2827 pub path: String,
2829 #[serde(default)]
2831 pub op: BodyPatchOp,
2832 #[serde(default, skip_serializing_if = "Option::is_none")]
2834 pub value: Option<PatchValue>,
2835}
2836
2837#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2839#[serde(rename_all = "snake_case")]
2840pub enum BodyPatchOp {
2841 #[default]
2842 Set,
2843 Remove,
2844}
2845
2846#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2848#[serde(untagged)]
2849pub enum PatchValue {
2850 Template(TemplateExpr),
2851 Json(Value),
2852}
2853
2854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2856#[serde(untagged)]
2857pub enum TemplateExpr {
2858 Literal(String),
2860 Structured(TemplateExprSpec),
2862}
2863
2864#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2866#[serde(tag = "type", rename_all = "snake_case")]
2867pub enum TemplateExprSpec {
2868 Literal { value: String },
2870 EnvRef {
2872 name: String,
2873 #[serde(default, skip_serializing_if = "Option::is_none")]
2874 fallback: Option<String>,
2875 },
2876 Generated { generator: GeneratedValue },
2878 Format { template: String },
2880}
2881
2882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2884#[serde(rename_all = "snake_case")]
2885pub enum GeneratedValue {
2886 Uuid,
2887 UnixMs,
2888}
2889
2890#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2892pub struct ToolsConfig {
2893 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2895 pub disabled: Vec<String>,
2896
2897 #[serde(default, flatten)]
2899 pub extra: BTreeMap<String, Value>,
2900}
2901
2902impl ToolsConfig {
2903 fn is_empty(&self) -> bool {
2904 self.disabled.is_empty() && self.extra.is_empty()
2905 }
2906}
2907
2908#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2910pub struct SkillsConfig {
2911 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2913 pub disabled: Vec<String>,
2914
2915 #[serde(default, flatten)]
2917 pub extra: BTreeMap<String, Value>,
2918}
2919
2920impl SkillsConfig {
2921 fn is_empty(&self) -> bool {
2922 self.disabled.is_empty() && self.extra.is_empty()
2923 }
2924}
2925
2926#[derive(Debug, Clone, Serialize, Deserialize)]
2931pub struct ImageFallbackHookConfig {
2932 #[serde(default = "default_true_hooks")]
2933 pub enabled: bool,
2934
2935 #[serde(default = "default_image_fallback_mode")]
2937 pub mode: String,
2938}
2939
2940impl Default for ImageFallbackHookConfig {
2941 fn default() -> Self {
2942 Self {
2943 enabled: default_true_hooks(),
2944 mode: default_image_fallback_mode(),
2945 }
2946 }
2947}
2948
2949fn default_image_fallback_mode() -> String {
2950 "placeholder".to_string()
2951}
2952
2953fn default_true_hooks() -> bool {
2954 false
2957}
2958
2959pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2971
2972#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2973pub struct OpenAIConfig {
2974 #[serde(default, skip_serializing)]
2978 pub api_key: String,
2979 #[serde(default, skip_serializing_if = "Option::is_none")]
2981 pub api_key_encrypted: Option<String>,
2982 #[serde(default, skip_serializing_if = "Option::is_none")]
2984 pub credential_ref: Option<crate::CredentialRef>,
2985 #[serde(skip)]
2990 pub api_key_from_env: bool,
2991 #[serde(skip_serializing_if = "Option::is_none")]
2993 pub base_url: Option<String>,
2994 #[serde(skip_serializing_if = "Option::is_none")]
2996 pub model: Option<String>,
2997 #[serde(default, skip_serializing_if = "Option::is_none")]
3000 pub fast_model: Option<String>,
3001 #[serde(default, skip_serializing_if = "Option::is_none")]
3004 pub vision_model: Option<String>,
3005 #[serde(skip_serializing_if = "Option::is_none")]
3007 pub reasoning_effort: Option<ReasoningEffort>,
3008
3009 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3016 pub responses_only_models: Vec<String>,
3017 #[serde(default, skip_serializing_if = "Option::is_none")]
3019 pub request_overrides: Option<RequestOverridesConfig>,
3020
3021 #[serde(default, flatten)]
3023 pub extra: BTreeMap<String, Value>,
3024}
3025
3026impl OpenAIConfig {
3027 pub fn explicit_prompt_cache_enabled(&self) -> bool {
3034 self.extra
3035 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3036 .and_then(Value::as_bool)
3037 .unwrap_or(true)
3038 }
3039}
3040
3041#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3053pub struct AnthropicConfig {
3054 #[serde(default, skip_serializing)]
3058 pub api_key: String,
3059 #[serde(default, skip_serializing_if = "Option::is_none")]
3061 pub api_key_encrypted: Option<String>,
3062 #[serde(default, skip_serializing_if = "Option::is_none")]
3064 pub credential_ref: Option<crate::CredentialRef>,
3065 #[serde(skip)]
3070 pub api_key_from_env: bool,
3071 #[serde(skip_serializing_if = "Option::is_none")]
3073 pub base_url: Option<String>,
3074 #[serde(skip_serializing_if = "Option::is_none")]
3076 pub model: Option<String>,
3077 #[serde(default, skip_serializing_if = "Option::is_none")]
3080 pub fast_model: Option<String>,
3081 #[serde(default, skip_serializing_if = "Option::is_none")]
3084 pub vision_model: Option<String>,
3085 #[serde(skip_serializing_if = "Option::is_none")]
3087 pub max_tokens: Option<u32>,
3088 #[serde(skip_serializing_if = "Option::is_none")]
3090 pub reasoning_effort: Option<ReasoningEffort>,
3091 #[serde(default, skip_serializing_if = "Option::is_none")]
3093 pub request_overrides: Option<RequestOverridesConfig>,
3094
3095 #[serde(default, skip_serializing_if = "Option::is_none")]
3112 pub thinking_replay_always: Option<bool>,
3113
3114 #[serde(default, flatten)]
3116 pub extra: BTreeMap<String, Value>,
3117}
3118
3119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3130pub struct GeminiConfig {
3131 #[serde(default, skip_serializing)]
3135 pub api_key: String,
3136 #[serde(default, skip_serializing_if = "Option::is_none")]
3138 pub api_key_encrypted: Option<String>,
3139 #[serde(default, skip_serializing_if = "Option::is_none")]
3141 pub credential_ref: Option<crate::CredentialRef>,
3142 #[serde(skip)]
3147 pub api_key_from_env: bool,
3148 #[serde(skip_serializing_if = "Option::is_none")]
3150 pub base_url: Option<String>,
3151 #[serde(skip_serializing_if = "Option::is_none")]
3153 pub model: Option<String>,
3154 #[serde(default, skip_serializing_if = "Option::is_none")]
3157 pub fast_model: Option<String>,
3158 #[serde(default, skip_serializing_if = "Option::is_none")]
3161 pub vision_model: Option<String>,
3162 #[serde(skip_serializing_if = "Option::is_none")]
3164 pub reasoning_effort: Option<ReasoningEffort>,
3165 #[serde(default, skip_serializing_if = "Option::is_none")]
3167 pub request_overrides: Option<RequestOverridesConfig>,
3168
3169 #[serde(default, flatten)]
3171 pub extra: BTreeMap<String, Value>,
3172}
3173
3174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3186pub struct CopilotConfig {
3187 #[serde(default)]
3189 pub enabled: bool,
3190 #[serde(default)]
3192 pub headless_auth: bool,
3193 #[serde(skip_serializing_if = "Option::is_none")]
3195 pub model: Option<String>,
3196 #[serde(default, skip_serializing_if = "Option::is_none")]
3199 pub fast_model: Option<String>,
3200 #[serde(default, skip_serializing_if = "Option::is_none")]
3203 pub vision_model: Option<String>,
3204 #[serde(skip_serializing_if = "Option::is_none")]
3206 pub reasoning_effort: Option<ReasoningEffort>,
3207
3208 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3217 pub responses_only_models: Vec<String>,
3218 #[serde(default, skip_serializing_if = "Option::is_none")]
3220 pub request_overrides: Option<RequestOverridesConfig>,
3221
3222 #[serde(default, flatten)]
3224 pub extra: BTreeMap<String, Value>,
3225}
3226
3227#[derive(Debug, Clone, Serialize, Deserialize)]
3232pub struct BodhiConfig {
3233 #[serde(default, skip_serializing)]
3235 pub api_key: String,
3236 #[serde(default, skip_serializing_if = "Option::is_none")]
3238 pub api_key_encrypted: Option<String>,
3239 #[serde(default, skip_serializing_if = "Option::is_none")]
3241 pub credential_ref: Option<crate::CredentialRef>,
3242 #[serde(skip_serializing_if = "Option::is_none")]
3244 pub base_url: Option<String>,
3245 #[serde(skip_serializing_if = "Option::is_none")]
3247 pub target_provider: Option<String>,
3248 #[serde(skip_serializing_if = "Option::is_none")]
3250 pub reasoning_effort: Option<ReasoningEffort>,
3251
3252 #[serde(default, flatten)]
3254 pub extra: BTreeMap<String, Value>,
3255}
3256
3257fn default_provider() -> String {
3259 "anthropic".to_string()
3260}
3261
3262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3294pub struct ProviderInstanceConfig {
3295 pub provider_type: String,
3300
3301 #[serde(default, skip_serializing_if = "Option::is_none")]
3303 pub label: Option<String>,
3304
3305 #[serde(default, skip_serializing)]
3307 pub api_key: String,
3308
3309 #[serde(default, skip_serializing_if = "Option::is_none")]
3312 pub api_key_encrypted: Option<String>,
3313
3314 #[serde(default, skip_serializing_if = "Option::is_none")]
3316 pub credential_ref: Option<crate::CredentialRef>,
3317
3318 #[serde(default, skip_serializing_if = "Option::is_none")]
3320 pub base_url: Option<String>,
3321
3322 #[serde(default, skip_serializing_if = "Option::is_none")]
3324 pub model: Option<String>,
3325
3326 #[serde(default, skip_serializing_if = "Option::is_none")]
3328 pub fast_model: Option<String>,
3329
3330 #[serde(default, skip_serializing_if = "Option::is_none")]
3332 pub vision_model: Option<String>,
3333
3334 #[serde(default, skip_serializing_if = "Option::is_none")]
3336 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3337
3338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3340 pub responses_only_models: Vec<String>,
3341
3342 #[serde(default, skip_serializing_if = "Option::is_none")]
3344 pub request_overrides: Option<RequestOverridesConfig>,
3345
3346 #[serde(default = "default_true")]
3349 pub enabled: bool,
3350
3351 #[serde(default, flatten)]
3353 pub extra: BTreeMap<String, Value>,
3354}
3355
3356fn default_true() -> bool {
3357 true
3358}
3359
3360fn default_port() -> u16 {
3362 9562
3363}
3364
3365fn default_bind() -> String {
3367 "127.0.0.1".to_string()
3368}
3369
3370fn default_workers() -> usize {
3372 10
3373}
3374
3375fn default_data_dir() -> PathBuf {
3377 super::paths::bamboo_dir()
3378}
3379
3380#[derive(Debug, Clone, Serialize, Deserialize)]
3382pub struct ServerConfig {
3383 #[serde(default = "default_port")]
3385 pub port: u16,
3386
3387 #[serde(default = "default_bind")]
3389 pub bind: String,
3390
3391 pub static_dir: Option<PathBuf>,
3393
3394 #[serde(default = "default_workers")]
3396 pub workers: usize,
3397
3398 #[serde(default, skip_serializing_if = "Option::is_none")]
3406 pub tls: Option<TlsConfig>,
3407
3408 #[serde(default, flatten)]
3410 pub extra: BTreeMap<String, Value>,
3411}
3412
3413impl Default for ServerConfig {
3414 fn default() -> Self {
3415 Self {
3416 port: default_port(),
3417 bind: default_bind(),
3418 static_dir: None,
3419 workers: default_workers(),
3420 tls: None,
3421 extra: BTreeMap::new(),
3422 }
3423 }
3424}
3425
3426#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3432pub struct TlsConfig {
3433 pub cert_file: PathBuf,
3435 pub key_file: PathBuf,
3437}
3438
3439#[derive(Debug, Clone, Serialize, Deserialize)]
3441pub struct ProxyAuth {
3442 pub username: String,
3444 pub password: String,
3446}
3447
3448fn parse_bool_env(value: &str) -> bool {
3452 matches!(
3453 value.trim().to_ascii_lowercase().as_str(),
3454 "1" | "true" | "yes" | "y" | "on"
3455 )
3456}
3457
3458fn expand_user_path(value: &str) -> PathBuf {
3459 let trimmed = value.trim();
3460 if let Some(rest) = trimmed.strip_prefix("~/") {
3461 if let Some(home) = dirs::home_dir() {
3462 return home.join(rest);
3463 }
3464 }
3465 PathBuf::from(trimmed)
3466}
3467
3468impl Default for Config {
3469 fn default() -> Self {
3470 Self::create_default()
3479 }
3480}
3481
3482#[derive(Debug, Clone, PartialEq, Eq)]
3484pub struct PromptSafeEnvVarEntry {
3485 pub name: String,
3486 pub secret: bool,
3487 pub description: Option<String>,
3488}
3489
3490static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3494
3495static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3496
3497fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3498 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3499}
3500
3501fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3502 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3503}
3504
3505impl Config {
3506 fn from_parts(
3507 values: ConfigValues,
3508 memory: Option<MemoryConfig>,
3509 subagents: SubagentsConfig,
3510 providers: ProviderConfigs,
3511 ) -> Self {
3512 Self {
3513 values,
3514 memory: crate::MemoryConfigModule(memory),
3515 subagents: crate::SubagentsConfigModule(subagents),
3516 providers: crate::ProviderConfigsModule(providers),
3517 recovery_status: None,
3518 }
3519 }
3520
3521 pub(crate) fn section_values(&self) -> ConfigValues {
3527 self.values.clone()
3528 }
3529
3530 pub(crate) fn from_section_parts(
3531 values: ConfigValues,
3532 memory: Option<MemoryConfig>,
3533 subagents: SubagentsConfig,
3534 providers: ProviderConfigs,
3535 ) -> Self {
3536 Self::from_parts(values, memory, subagents, providers)
3537 }
3538
3539 pub fn memory(&self) -> &Option<MemoryConfig> {
3541 &self.memory.0
3542 }
3543
3544 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3545 &mut self.memory.0
3546 }
3547
3548 pub fn subagents(&self) -> &SubagentsConfig {
3550 &self.subagents.0
3551 }
3552
3553 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3554 &mut self.subagents.0
3555 }
3556
3557 pub fn providers(&self) -> &ProviderConfigs {
3559 &self.providers.0
3560 }
3561
3562 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3563 &mut self.providers.0
3564 }
3565
3566 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3570 if self
3571 .default_provider_instance
3572 .as_ref()
3573 .is_some_and(|id| self.provider_instances.contains_key(id))
3574 {
3575 self.providers.0.clear_legacy_builtin_aliases();
3576 }
3577 }
3578
3579 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3585 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3586 let object = value
3587 .as_object_mut()
3588 .expect("ConfigRoot always serializes as a JSON object");
3589 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3590 object.insert(
3591 "subagents".to_string(),
3592 serde_json::to_value(self.subagents())?,
3593 );
3594 object.insert(
3595 "providers".to_string(),
3596 serde_json::to_value(self.providers())?,
3597 );
3598 Ok(value)
3599 }
3600
3601 pub fn new() -> Self {
3620 Self::from_data_dir(None)
3621 }
3622
3623 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3624 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3625 Ok(facade) => facade.effective_config(),
3626 Err(error) => {
3627 tracing::warn!(
3628 error = %error,
3629 "completed modular configuration is unavailable; refusing legacy-root fallback"
3630 );
3631 Self::create_default()
3632 }
3633 };
3634
3635 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3636 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3637 config.proxy_auth = None;
3638 }
3639 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3640 tracing::warn!(error = %error, "provider credential hydration unavailable");
3641 }
3642 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3643 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3644 }
3645 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3646 tracing::warn!(error = %error, "env credential hydration unavailable");
3647 for entry in &mut config.env_vars {
3648 if entry.secret {
3649 entry.value.clear();
3650 }
3651 }
3652 }
3653 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3654 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3655 config.clear_cluster_runtime_credentials();
3656 }
3657 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3658 tracing::warn!(error = %error, "notification credential hydration unavailable");
3659 config.notifications.ntfy.token = None;
3660 config.notifications.bark.device_key = None;
3661 }
3662 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3663 tracing::warn!(error = %error, "connect credential hydration unavailable");
3664 for platform in &mut config.connect.platforms {
3665 platform.token = None;
3666 platform.app_secret = None;
3667 }
3668 }
3669 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3670 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3671 config.clear_access_control_runtime_verifiers();
3672 }
3673 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3674 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3675 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3676 broker.token.clear();
3677 }
3678 }
3679 config.normalize_tool_settings();
3680 config.normalize_skill_settings();
3681 config.normalize_plugin_trust_settings();
3682 config.extra.remove("data_dir");
3683 if apply_env {
3684 config.apply_env_overrides();
3685 }
3686 if publish {
3687 config.publish_env_vars();
3688 }
3689 config
3690 }
3691
3692 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3701 let data_dir = data_dir
3703 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3704 .unwrap_or_else(default_data_dir);
3705
3706 match crate::modular_authority_boundary_present(&data_dir) {
3707 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3708 Ok(false) => {}
3709 Err(error) => {
3710 tracing::warn!(
3711 error = %error,
3712 "modular configuration marker is unavailable; refusing legacy-root fallback"
3713 );
3714 let mut config = Self::create_default();
3715 if apply_env {
3716 config.apply_env_overrides();
3717 }
3718 if publish {
3719 config.publish_env_vars();
3720 }
3721 return config;
3722 }
3723 }
3724
3725 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3730 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3731 .map_err(|error| {
3732 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3733 error
3734 })
3735 .is_ok();
3736 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3737 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3738 .map_err(|error| {
3739 tracing::warn!(error = %error, "cluster credential migration unavailable");
3740 error
3741 })
3742 .is_ok();
3743
3744 match crate::modular_authority_boundary_present(&data_dir) {
3749 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3750 Ok(false) => {}
3751 Err(error) => {
3752 tracing::warn!(
3753 error = %error,
3754 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3755 );
3756 let mut config = Self::create_default();
3757 if apply_env {
3758 config.apply_env_overrides();
3759 }
3760 if publish {
3761 config.publish_env_vars();
3762 }
3763 return config;
3764 }
3765 }
3766
3767 let config_path = data_dir.join("config.json");
3768
3769 let mut config = if config_path.exists() {
3770 if let Ok(content) = std::fs::read_to_string(&config_path) {
3771 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3772 tracing::warn!(
3782 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3783 e
3784 );
3785 let quarantine_path = quarantine_corrupt_config(&config_path);
3786 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3787 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3788 .or_else(|| {
3789 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3790 (cfg, ConfigRecoverySource::Backup { generation })
3791 })
3792 })
3793 .unwrap_or_else(|| {
3794 tracing::warn!(
3795 "Could not salvage and no usable config.json.bak; using defaults"
3796 );
3797 (Self::create_default(), ConfigRecoverySource::Defaults)
3798 });
3799 recovered.recovery_status = Some(ConfigRecoveryStatus {
3800 source,
3801 quarantine_path,
3802 confirmed: false,
3803 });
3804 recovered
3805 })
3806 } else {
3807 Self::create_default()
3808 }
3809 } else {
3810 Self::create_default()
3811 };
3812
3813 let mut memory_module = config.memory.clone();
3819 match memory_module.load_sync(&data_dir) {
3820 Ok(true) => config.memory = memory_module,
3821 Ok(false) => {}
3822 Err(error) => tracing::warn!(
3823 "Failed to load memory.json; using legacy config.json memory: {error}"
3824 ),
3825 }
3826 let mut subagents_module = config.subagents.clone();
3827 match subagents_module.load_sync(&data_dir) {
3828 Ok(true) => config.subagents = subagents_module,
3829 Ok(false) => {}
3830 Err(error) => tracing::warn!(
3831 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3832 ),
3833 }
3834 if provider_mcp_ready {
3835 let mut providers_module = config.providers.clone();
3836 match providers_module.load_sync(&data_dir) {
3837 Ok(true) => config.providers = providers_module,
3838 Ok(false) => {}
3839 Err(error) => tracing::warn!(
3840 "Failed to load providers.json; using legacy config.json providers: {error}"
3841 ),
3842 }
3843 }
3844
3845 config.hydrate_proxy_auth_from_encrypted();
3847 if provider_mcp_ready {
3848 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3849 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3850 config.proxy_auth = None;
3851 }
3852 }
3853 config.hydrate_provider_api_keys_from_encrypted();
3855 config.hydrate_provider_instance_api_keys_from_encrypted();
3857 config.hydrate_mcp_secrets_from_encrypted();
3859 if provider_mcp_ready {
3860 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3861 tracing::warn!(error = %error, "provider credential hydration unavailable");
3862 }
3863 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3864 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3865 }
3866 }
3867 config.hydrate_env_vars_from_encrypted();
3869 if provider_mcp_ready {
3870 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3871 tracing::warn!(error = %error, "env credential hydration unavailable");
3872 for entry in &mut config.env_vars {
3873 if entry.secret {
3874 entry.value.clear();
3875 }
3876 }
3877 }
3878 }
3879 if cluster_ready {
3884 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3885 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3886 config.clear_cluster_runtime_credentials();
3887 }
3888 } else {
3889 config.clear_cluster_runtime_credentials();
3890 }
3891 config.hydrate_broker_token_from_encrypted();
3893 config.hydrate_notifications_from_encrypted();
3895 if provider_mcp_ready {
3896 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3897 tracing::warn!(error = %error, "notification credential hydration unavailable");
3898 config.notifications.ntfy.token = None;
3899 config.notifications.bark.device_key = None;
3900 }
3901 } else {
3902 config.notifications.ntfy.token = None;
3907 config.notifications.bark.device_key = None;
3908 }
3909 config.merge_connect_config(&data_dir);
3914 scrub_legacy_connect_from_config_backups(&data_dir);
3924 config.hydrate_connect_platform_tokens_from_encrypted();
3926 if provider_mcp_ready {
3927 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3928 tracing::warn!(error = %error, "connect credential hydration unavailable");
3929 for platform in &mut config.connect.platforms {
3930 platform.token = None;
3931 platform.app_secret = None;
3932 }
3933 }
3934 }
3935 if provider_mcp_ready {
3936 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3937 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3938 config.clear_access_control_runtime_verifiers();
3939 }
3940 } else {
3941 config.clear_access_control_runtime_verifiers();
3942 }
3943 config.normalize_tool_settings();
3944 config.normalize_skill_settings();
3945 config.normalize_plugin_trust_settings();
3946
3947 config.extra.remove("data_dir");
3950
3951 if apply_env {
3955 config.apply_env_overrides();
3956 }
3957
3958 if publish {
3962 config.publish_env_vars();
3963 }
3964
3965 config
3966 }
3967
3968 fn apply_env_overrides(&mut self) {
3972 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3973 if let Ok(port) = port.parse() {
3974 self.server.port = port;
3975 }
3976 }
3977
3978 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3979 self.server.bind = bind;
3980 }
3981
3982 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3987 let provider = provider.trim().to_string();
3988 if !provider.is_empty() {
3989 self.provider = provider.clone();
3990 if !self.provider_instances.is_empty() {
3991 let selected = self
3992 .provider_instances
3993 .contains_key(&provider)
3994 .then_some(provider.clone())
3995 .or_else(|| {
3996 let mut matching = self
3997 .provider_instances
3998 .iter()
3999 .filter(|(_, instance)| {
4000 instance.enabled && instance.provider_type == provider
4001 })
4002 .map(|(id, _)| id.clone())
4003 .collect::<Vec<_>>();
4004 matching.sort();
4005 matching.into_iter().next()
4006 });
4007 self.default_provider_instance = selected;
4008 }
4009 }
4010 }
4011
4012 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4013 self.headless_auth = parse_bool_env(&headless);
4014 }
4015
4016 if let Ok(project_prompt_injection) =
4017 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4018 {
4019 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4020 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4021 }
4022
4023 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4024 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4025 memory.relevant_recall = parse_bool_env(&relevant_recall);
4026 }
4027
4028 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4029 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4030 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4031 }
4032
4033 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4034 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4035 memory.project_first_dream = parse_bool_env(&project_first_dream);
4036 }
4037
4038 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4045 let key = key.trim();
4046 if !key.is_empty() {
4047 if self.provider_instances.is_empty() {
4048 let openai = self
4049 .providers
4050 .openai
4051 .get_or_insert_with(OpenAIConfig::default);
4052 openai.api_key = key.to_string();
4053 openai.api_key_from_env = true;
4054 } else {
4055 self.apply_provider_instance_env_key("openai", key);
4056 }
4057 }
4058 }
4059 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4060 let key = key.trim();
4061 if !key.is_empty() {
4062 if self.provider_instances.is_empty() {
4063 let anthropic = self
4064 .providers
4065 .anthropic
4066 .get_or_insert_with(AnthropicConfig::default);
4067 anthropic.api_key = key.to_string();
4068 anthropic.api_key_from_env = true;
4069 } else {
4070 self.apply_provider_instance_env_key("anthropic", key);
4071 }
4072 }
4073 }
4074 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4075 let key = key.trim();
4076 if !key.is_empty() {
4077 if self.provider_instances.is_empty() {
4078 let gemini = self
4079 .providers
4080 .gemini
4081 .get_or_insert_with(GeminiConfig::default);
4082 gemini.api_key = key.to_string();
4083 gemini.api_key_from_env = true;
4084 } else {
4085 self.apply_provider_instance_env_key("gemini", key);
4086 }
4087 }
4088 }
4089 }
4090
4091 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4092 for instance in self.provider_instances.values_mut().filter(|instance| {
4093 instance.provider_type == provider_type
4094 && crate::provider_instance_api_key_from_env(instance)
4095 }) {
4096 instance.api_key = key.to_string();
4097 instance.api_key_encrypted = None;
4098 }
4099 }
4100
4101 pub fn apply_runtime_env_overrides(&mut self) {
4106 self.apply_env_overrides();
4107 }
4108
4109 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4115 Self::from_data_dir_impl(data_dir, true, true)
4116 }
4117
4118 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4122 Self::from_data_dir_impl(data_dir, false, true)
4123 }
4124
4125 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4132 Self::from_data_dir_impl(data_dir, false, false)
4133 }
4134
4135 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4163 let connect_path = data_dir.join("connect.json");
4164 match std::fs::read_to_string(&connect_path) {
4165 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4166 Ok(connect) => {
4167 let legacy_key_present = !connect_config_is_empty(&self.connect);
4168 if legacy_key_present {
4169 tracing::warn!(
4170 "config.json still has a legacy `connect` key alongside \
4171 connect.json; connect.json takes precedence — dropping the \
4172 stale key from config.json now"
4173 );
4174 }
4175 self.connect = connect;
4176 if legacy_key_present {
4177 strip_legacy_connect_key_from_config_json(data_dir);
4178 }
4179 }
4180 Err(e) => {
4181 tracing::error!(
4182 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4183 connect config instead of falling back to a legacy config.json copy",
4184 connect_path,
4185 e
4186 );
4187 quarantine_corrupt_connect(&connect_path);
4188 self.connect = ConnectConfig::default();
4189 }
4190 },
4191 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4192 if !connect_config_is_empty(&self.connect) {
4193 tracing::info!(
4194 "Migrating legacy `connect` config from config.json to a \
4195 standalone connect.json"
4196 );
4197 strip_legacy_connect_key_from_config_json(data_dir);
4201 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4202 tracing::error!("Failed to write connect.json during migration: {}", e);
4203 }
4204 }
4205 }
4206 Err(e) => {
4207 tracing::error!(
4208 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4209 connect_path,
4210 e
4211 );
4212 self.connect = ConnectConfig::default();
4213 }
4214 }
4215 }
4216
4217 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4220 serde_json::from_str::<Config>(content).map(|mut config| {
4221 config.hydrate_proxy_auth_from_encrypted();
4222 config.hydrate_provider_api_keys_from_encrypted();
4223 config.hydrate_provider_instance_api_keys_from_encrypted();
4224 config.hydrate_mcp_secrets_from_encrypted();
4225 config.hydrate_env_vars_from_encrypted();
4226 config.hydrate_cluster_fabric_from_encrypted();
4227 config.hydrate_broker_token_from_encrypted();
4228 config.hydrate_notifications_from_encrypted();
4229 config.hydrate_connect_platform_tokens_from_encrypted();
4230 config.normalize_tool_settings();
4231 config.normalize_skill_settings();
4232 config
4233 })
4234 }
4235
4236 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4242 let config_path = data_dir.join("config.json");
4243 for gen in 0..BAK_GENERATIONS {
4244 let backup = backup_path_for(&config_path, gen);
4245 let Ok(content) = std::fs::read_to_string(&backup) else {
4246 continue;
4247 };
4248 match Self::parse_and_hydrate(&content) {
4249 Ok(config) => {
4250 tracing::info!("Recovered configuration from {:?}", backup);
4251 return Some((config, gen));
4252 }
4253 Err(e) => {
4254 tracing::warn!(
4255 "Backup {:?} is unparseable ({}); trying an older generation",
4256 backup,
4257 e
4258 );
4259 }
4260 }
4261 }
4262 None
4263 }
4264
4265 const SALVAGE_MAX_KEYS: usize = 512;
4271
4272 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4299 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4302 let corrupt_obj = corrupt.as_object()?;
4303 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4304 tracing::warn!(
4305 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4306 corrupt_obj.len(),
4307 Self::SALVAGE_MAX_KEYS
4308 );
4309 return None;
4310 }
4311
4312 let mut base = Self::load_backup(data_dir)
4316 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4317 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4318 let base_obj = base.as_object_mut()?;
4319
4320 let mut salvaged: Vec<String> = Vec::new();
4321 for (key, value) in corrupt_obj {
4322 let previous = base_obj.insert(key.clone(), value.clone());
4323 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4327 salvaged.push(key.clone());
4328 } else {
4329 match previous {
4330 Some(prev) => {
4331 base_obj.insert(key.clone(), prev);
4332 }
4333 None => {
4334 base_obj.remove(key);
4335 }
4336 }
4337 }
4338 }
4339
4340 tracing::warn!(
4341 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4342 last-known-good/default value",
4343 salvaged.len(),
4344 salvaged.join(", ")
4345 );
4346
4347 let rebuilt = serde_json::to_string(&base).ok()?;
4351 Self::parse_and_hydrate(&rebuilt)
4352 .ok()
4353 .map(|config| (config, salvaged))
4354 }
4355
4356 pub fn get_model(&self) -> Option<String> {
4364 if self.features.provider_model_ref {
4365 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4366 return Some(model_ref.model.clone());
4367 }
4368 }
4369 let provider = self.effective_default_provider();
4370 if let Some(instance) = self.provider_instances.get(provider) {
4371 return instance
4372 .model
4373 .clone()
4374 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4375 }
4376 match provider {
4377 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4378 "anthropic" => self
4379 .providers
4380 .anthropic
4381 .as_ref()
4382 .and_then(|c| c.model.clone()),
4383 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4384 "copilot" => Some(
4385 self.providers
4386 .copilot
4387 .as_ref()
4388 .and_then(|c| c.model.clone())
4389 .unwrap_or_else(|| "gpt-4o".to_string()),
4390 ),
4391 _ => None,
4392 }
4393 }
4394
4395 pub fn get_fast_model(&self) -> Option<String> {
4403 if self.features.provider_model_ref {
4404 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4405 return Some(model_ref.model.clone());
4406 }
4407 }
4408 let provider = self.effective_default_provider();
4409 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4410 instance.fast_model.clone()
4411 } else {
4412 match provider {
4413 "openai" => self
4414 .providers
4415 .openai
4416 .as_ref()
4417 .and_then(|c| c.fast_model.clone()),
4418 "anthropic" => self
4419 .providers
4420 .anthropic
4421 .as_ref()
4422 .and_then(|c| c.fast_model.clone()),
4423 "gemini" => self
4424 .providers
4425 .gemini
4426 .as_ref()
4427 .and_then(|c| c.fast_model.clone()),
4428 "copilot" => self
4429 .providers
4430 .copilot
4431 .as_ref()
4432 .and_then(|c| c.fast_model.clone()),
4433 _ => None,
4434 }
4435 };
4436 fast.or_else(|| self.get_model())
4437 }
4438
4439 pub fn get_task_summary_model(&self) -> Option<String> {
4447 if self.features.provider_model_ref {
4448 if let Some(model_ref) = self
4449 .defaults
4450 .as_ref()
4451 .and_then(|d| d.task_summary.as_ref())
4452 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4453 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4454 {
4455 return Some(model_ref.model.clone());
4456 }
4457 }
4458
4459 self.get_fast_model()
4460 }
4461
4462 pub fn get_memory_background_model(&self) -> Option<String> {
4470 if self.features.provider_model_ref {
4471 if let Some(model_ref) = self
4472 .defaults
4473 .as_ref()
4474 .and_then(|d| d.memory_background.as_ref())
4475 {
4476 return Some(model_ref.model.clone());
4477 }
4478 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4479 return Some(model_ref.model.clone());
4480 }
4481 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4482 return Some(model_ref.model.clone());
4483 }
4484 }
4485 let configured = self
4486 .memory
4487 .as_ref()
4488 .and_then(|memory| memory.background_model.as_ref())
4489 .map(|value| value.trim())
4490 .filter(|value| !value.is_empty())
4491 .map(ToString::to_string);
4492 configured.or_else(|| self.get_fast_model())
4493 }
4494
4495 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4503 let raw = self
4504 .default_work_area
4505 .as_ref()
4506 .and_then(|config| config.path.as_ref())
4507 .map(|value| value.trim())
4508 .filter(|value| !value.is_empty())?;
4509
4510 let candidate = expand_user_path(raw);
4511 if candidate.is_absolute() {
4512 let canonical = std::fs::canonicalize(&candidate).ok();
4513 return canonical
4514 .as_ref()
4515 .filter(|path| path.is_dir())
4516 .map(|_| candidate.clone())
4517 .or_else(|| candidate.is_dir().then_some(candidate));
4518 }
4519
4520 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4521 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4522 canonical
4523 .as_ref()
4524 .filter(|path| path.is_dir())
4525 .map(|_| from_bamboo_dir.clone())
4526 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4527 .or_else(|| candidate.is_dir().then_some(candidate))
4528 }
4529
4530 pub fn get_vision_model(&self) -> Option<String> {
4536 if self.features.provider_model_ref {
4537 if let Some(model_ref) = self
4538 .defaults
4539 .as_ref()
4540 .and_then(|defaults| defaults.vision.as_ref())
4541 .or_else(|| {
4542 self.defaults
4543 .as_ref()
4544 .and_then(|defaults| defaults.fast.as_ref())
4545 })
4546 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4547 {
4548 return Some(model_ref.model.clone());
4549 }
4550 }
4551 let provider = self.effective_default_provider();
4552 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4553 instance.vision_model.clone()
4554 } else {
4555 match provider {
4556 "openai" => self
4557 .providers
4558 .openai
4559 .as_ref()
4560 .and_then(|c| c.vision_model.clone()),
4561 "anthropic" => self
4562 .providers
4563 .anthropic
4564 .as_ref()
4565 .and_then(|c| c.vision_model.clone()),
4566 "gemini" => self
4567 .providers
4568 .gemini
4569 .as_ref()
4570 .and_then(|c| c.vision_model.clone()),
4571 "copilot" => self
4572 .providers
4573 .copilot
4574 .as_ref()
4575 .and_then(|c| c.vision_model.clone()),
4576 _ => None,
4577 }
4578 };
4579 vision.or_else(|| self.get_fast_model())
4580 }
4581
4582 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4586 if self.features.provider_model_ref {
4587 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4588 return chat
4589 .reasoning_effort
4590 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4591 }
4592 }
4593 self.reasoning_effort_for_key(self.effective_default_provider())
4594 }
4595
4596 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4606 let trimmed = key.trim();
4607 if trimmed.is_empty() {
4608 return None;
4609 }
4610
4611 if let Some(instance) = self.provider_instances.get(trimmed) {
4613 return instance.reasoning_effort;
4614 }
4615
4616 match trimmed {
4618 "openai" => self
4619 .providers
4620 .openai
4621 .as_ref()
4622 .and_then(|c| c.reasoning_effort),
4623 "anthropic" => self
4624 .providers
4625 .anthropic
4626 .as_ref()
4627 .and_then(|c| c.reasoning_effort),
4628 "gemini" => self
4629 .providers
4630 .gemini
4631 .as_ref()
4632 .and_then(|c| c.reasoning_effort),
4633 "copilot" => self
4634 .providers
4635 .copilot
4636 .as_ref()
4637 .and_then(|c| c.reasoning_effort),
4638 "bodhi" => self
4639 .providers
4640 .bodhi
4641 .as_ref()
4642 .and_then(|c| c.reasoning_effort),
4643 _ => None,
4644 }
4645 }
4646
4647 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4654 self.tools
4655 .disabled
4656 .iter()
4657 .map(|name| name.trim())
4658 .filter(|name| !name.is_empty())
4659 .map(str::to_string)
4660 .collect()
4661 }
4662
4663 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4668 self.disabled_tool_references()
4669 .into_iter()
4670 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4671 .collect()
4672 }
4673
4674 pub fn normalize_tool_settings(&mut self) {
4676 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4677 }
4678
4679 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4681 self.skills
4682 .disabled
4683 .iter()
4684 .map(|id| id.trim())
4685 .filter(|id| !id.is_empty())
4686 .map(|id| id.to_string())
4687 .collect()
4688 }
4689
4690 pub fn normalize_skill_settings(&mut self) {
4692 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4693 }
4694
4695 pub fn normalize_plugin_trust_settings(&mut self) {
4703 self.plugin_trust.trusted_hosts = self
4704 .plugin_trust
4705 .trusted_hosts
4706 .iter()
4707 .map(|entry| entry.trim().to_ascii_lowercase())
4708 .filter(|entry| !entry.is_empty())
4709 .collect();
4710 }
4711
4712 pub fn effective_default_provider(&self) -> &str {
4717 self.default_provider_instance
4718 .as_deref()
4719 .unwrap_or(&self.provider)
4720 }
4721
4722 pub fn has_provider_instances(&self) -> bool {
4724 !self.provider_instances.is_empty()
4725 }
4726
4727 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4729 self.env_vars
4730 .iter()
4731 .filter(|e| !e.value.trim().is_empty())
4732 .map(|e| (e.name.clone(), e.value.clone()))
4733 .collect()
4734 }
4735
4736 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4737 self.env_vars
4738 .iter()
4739 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4740 .map(|entry| PromptSafeEnvVarEntry {
4741 name: entry.name.clone(),
4742 secret: entry.secret,
4743 description: entry
4744 .description
4745 .as_ref()
4746 .map(|value| value.trim().to_string())
4747 .filter(|value| !value.is_empty()),
4748 })
4749 .collect()
4750 }
4751
4752 pub fn publish_env_vars(&self) {
4754 let map = self.env_vars_as_map();
4755
4756 #[cfg(any(test, feature = "test-utils"))]
4757 if crate::test_support::env_vars_cache_override_is_active() {
4758 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4759 return;
4760 }
4761
4762 let mut env_guard = env_vars_cache().write().recover_poison();
4763 *env_guard = map;
4764
4765 let prompt_safe = self.prompt_safe_env_vars();
4766 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4767 *prompt_guard = prompt_safe;
4768 }
4769
4770 pub fn current_env_vars() -> HashMap<String, String> {
4772 #[cfg(any(test, feature = "test-utils"))]
4773 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4774 return env_vars;
4775 }
4776
4777 env_vars_cache().read().recover_poison().clone()
4778 }
4779
4780 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4782 #[cfg(any(test, feature = "test-utils"))]
4783 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4784 return env_vars;
4785 }
4786
4787 prompt_safe_env_vars_cache().read().recover_poison().clone()
4788 }
4789
4790 fn create_default() -> Self {
4792 Self::from_parts(
4793 ConfigValues {
4794 http_proxy: String::new(),
4795 https_proxy: String::new(),
4796 proxy_auth: None,
4797 proxy_auth_encrypted: None,
4798 proxy_auth_credential_ref: None,
4799 headless_auth: false,
4800 run_budget: RunBudgetConfig::default(),
4801 stream_timeout: StreamTimeoutConfig::default(),
4802 context_management: ContextManagementConfig::default(),
4803 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4804 provider: default_provider(),
4805 provider_instances: HashMap::new(),
4806 default_provider_instance: None,
4807 server: ServerConfig::default(),
4808 keyword_masking: KeywordMaskingConfig::default(),
4809 anthropic_model_mapping: AnthropicModelMapping::default(),
4810 gemini_model_mapping: GeminiModelMapping::default(),
4811 hooks: HooksConfig::default(),
4812 lifecycle_hooks: LifecycleHooksConfig::default(),
4813 tools: ToolsConfig::default(),
4814 skills: SkillsConfig::default(),
4815 env_vars: Vec::new(),
4816 default_work_area: None,
4817 worktrees: WorktreeSettings::default(),
4818 access_control: None,
4819 features: FeatureFlags::default(),
4820 defaults: None,
4821 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4822 notifications: NotificationsConfig::default(),
4823 connect: ConnectConfig::default(),
4824 plugin_trust: PluginTrustConfig::default(),
4825 extra: BTreeMap::new(),
4826 },
4827 None,
4828 SubagentsConfig::default(),
4829 ProviderConfigs::default(),
4830 )
4831 }
4832
4833 pub fn server_addr(&self) -> String {
4835 format!("{}:{}", self.server.bind, self.server.port)
4836 }
4837
4838 pub fn save(&self) -> Result<()> {
4840 self.save_to_dir(default_data_dir())
4841 }
4842
4843 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4846 self.memory.save_sync(data_dir)
4847 }
4848
4849 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4852 self.subagents.save_sync(data_dir)
4853 }
4854
4855 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4859 crate::config_module::ensure_legacy_module_layout(data_dir)?;
4860 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4861 let mut config = self.clone();
4862 config.clear_legacy_provider_aliases_for_instance_mode();
4863 config.refresh_provider_api_keys_encrypted()?;
4864 config.providers.save_sync(data_dir)
4865 }
4866
4867 pub(crate) fn prepare_provider_transaction_documents(
4871 &self,
4872 provider_document: &[u8],
4873 ) -> Result<(Vec<u8>, Vec<u8>)> {
4874 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4875 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4876 anyhow::bail!(
4877 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4878 status.source
4879 );
4880 }
4881
4882 let mut to_save = self.clone();
4883 to_save.clear_legacy_provider_aliases_for_instance_mode();
4884 to_save.extra.remove("data_dir");
4885 to_save.extra.remove("model");
4886 to_save.refresh_encrypted_secrets()?;
4887 to_save.ensure_provider_instance_credentials_isolated()?;
4888 to_save.sanitize_mcp_credential_refs_for_disk();
4889 to_save.sanitize_env_vars_for_disk();
4890 to_save.sanitize_notifications_for_disk();
4891 to_save.sanitize_cluster_fabric_for_disk();
4892 to_save.assign_connect_platform_ids();
4893 to_save.normalize_tool_settings();
4894 to_save.normalize_skill_settings();
4895
4896 let mut root = durable_root_value(to_save.values.clone())
4897 .context("Failed to serialize root config DTO to JSON")?;
4898 if let Some(object) = root.as_object_mut() {
4899 object.remove("connect");
4900 }
4901 let root = serde_json::to_vec_pretty(&root)?;
4902
4903 let mut providers = to_save.providers.0.clone();
4904 macro_rules! sanitize_provider {
4905 ($field:ident) => {
4906 if let Some(provider) = providers.$field.as_mut() {
4907 if !provider.api_key.trim().is_empty()
4908 && !provider.api_key_from_env
4909 && provider.credential_ref.is_none()
4910 {
4911 anyhow::bail!(
4912 "provider secret requires credential transaction before persistence"
4913 );
4914 }
4915 provider.api_key_encrypted = None;
4916 }
4917 };
4918 }
4919 sanitize_provider!(openai);
4920 sanitize_provider!(anthropic);
4921 sanitize_provider!(gemini);
4922 if let Some(provider) = providers.bodhi.as_mut() {
4923 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4924 anyhow::bail!("provider secret requires credential transaction before persistence");
4925 }
4926 provider.api_key_encrypted = None;
4927 }
4928
4929 let existing_provider_value = if provider_document.is_empty() {
4930 None
4931 } else {
4932 Some(
4933 serde_json::from_slice::<Value>(provider_document)
4934 .context("provider metadata document is invalid")?,
4935 )
4936 };
4937 let provider_value = match existing_provider_value {
4938 Some(Value::Object(mut envelope))
4939 if envelope.contains_key("schema_version")
4940 || envelope.contains_key("revision")
4941 || envelope.contains_key("data") =>
4942 {
4943 let revision = envelope
4944 .get("revision")
4945 .and_then(Value::as_u64)
4946 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4947 let schema_version = envelope
4948 .get("schema_version")
4949 .and_then(Value::as_u64)
4950 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4951 if schema_version != 1 || !envelope.contains_key("data") {
4952 anyhow::bail!("provider revision envelope is unsupported");
4953 }
4954 let revision = revision
4955 .checked_add(1)
4956 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4957 envelope.insert("revision".into(), Value::from(revision));
4958 envelope.insert("data".into(), serde_json::to_value(providers)?);
4959 Value::Object(envelope)
4960 }
4961 Some(_) | None => serde_json::to_value(providers)?,
4962 };
4963 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4964 }
4965
4966 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4970 self.recovery_status.as_ref()
4971 }
4972
4973 pub fn confirm_recovery(&mut self) {
4979 if let Some(status) = self.recovery_status.as_mut() {
4980 status.confirmed = true;
4981 }
4982 }
4983
4984 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4992 if self.recovery_status.is_none() {
4993 anyhow::bail!("No pending config-corruption recovery to confirm");
4994 }
4995 self.confirm_recovery();
4996 self.save_to_dir(data_dir)?;
4997 self.recovery_status = None;
4998 Ok(())
4999 }
5000
5001 pub fn assign_connect_platform_ids(&mut self) {
5017 for platform in &mut self.connect.platforms {
5018 if platform.id.is_none() {
5019 platform.id = Some(uuid::Uuid::new_v4().to_string());
5020 }
5021 }
5022 }
5023
5024 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5036 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5037 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5038 anyhow::bail!(
5039 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5040 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5041 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5042 status.source,
5043 status.quarantine_path,
5044 );
5045 }
5046 if self.proxy_auth_credential_ref.is_none()
5047 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5048 {
5049 anyhow::bail!(
5050 "proxy auth requires the isolated credential transaction before persistence"
5051 );
5052 }
5053 if self.env_vars.iter().any(|entry| {
5054 entry.secret
5055 && entry.credential_ref.is_none()
5056 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5057 }) {
5058 anyhow::bail!(
5059 "secret env vars require the isolated credential transaction before persistence"
5060 );
5061 }
5062 if [
5063 (
5064 &self.notifications.ntfy.token,
5065 &self.notifications.ntfy.token_encrypted,
5066 &self.notifications.ntfy.credential_ref,
5067 ),
5068 (
5069 &self.notifications.bark.device_key,
5070 &self.notifications.bark.device_key_encrypted,
5071 &self.notifications.bark.credential_ref,
5072 ),
5073 ]
5074 .iter()
5075 .any(|(plaintext, ciphertext, reference)| {
5076 reference.is_none()
5077 && (plaintext
5078 .as_deref()
5079 .is_some_and(|value| !value.trim().is_empty())
5080 || ciphertext.is_some())
5081 }) {
5082 anyhow::bail!(
5083 "notification secrets require the isolated credential transaction before persistence"
5084 );
5085 }
5086
5087 if crate::modular_authority_boundary_present(&data_dir)
5088 .context("Failed to inspect modular configuration authority boundary")?
5089 {
5090 crate::ConfigFacade::open_or_migrate(&data_dir)
5091 .context("Failed to recover modular configuration before persistence")?;
5092 crate::persist_facade_effective_config(&data_dir, self)
5093 .context("Failed to persist modular configuration sections")?;
5094 return Ok(());
5095 }
5096
5097 if crate::section_layout_is_active(&data_dir)
5098 .context("Failed to inspect modular configuration layout")?
5099 {
5100 crate::persist_facade_effective_config(&data_dir, self)
5101 .context("Failed to persist modular configuration sections")?;
5102 return Ok(());
5103 }
5104
5105 let path = data_dir.join("config.json");
5106
5107 if let Some(parent) = path.parent() {
5108 std::fs::create_dir_all(parent)
5109 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5110 }
5111
5112 let mut to_save = self.clone();
5113 to_save.clear_legacy_provider_aliases_for_instance_mode();
5114 to_save.extra.remove("data_dir");
5116 to_save.extra.remove("model");
5118 to_save.refresh_encrypted_secrets()?;
5121 to_save.ensure_provider_instance_credentials_isolated()?;
5122 to_save.sanitize_mcp_credential_refs_for_disk();
5123 to_save.sanitize_env_vars_for_disk();
5124 to_save.sanitize_notifications_for_disk();
5125 to_save.sanitize_cluster_fabric_for_disk();
5126 to_save.assign_connect_platform_ids();
5127 to_save.normalize_tool_settings();
5128 to_save.normalize_skill_settings();
5129
5130 let mut config_value = durable_root_value(to_save.values.clone())
5140 .context("Failed to serialize root config DTO to JSON")?;
5141 if let Some(obj) = config_value.as_object_mut() {
5142 obj.remove("connect");
5143 }
5144 let content = serde_json::to_string_pretty(&config_value)
5145 .context("Failed to serialize config to JSON")?;
5146
5147 to_save.memory.save_sync(&data_dir)?;
5154 to_save.subagents.save_sync(&data_dir)?;
5155 to_save.providers.save_sync(&data_dir)?;
5156
5157 if path.exists()
5164 && std::fs::read_to_string(&path)
5165 .ok()
5166 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5167 {
5168 rotate_backups(&path, BAK_GENERATIONS);
5172 let backup = backup_path_for(&path, 0);
5173 let backup_result = std::fs::read(&path).and_then(|bytes| {
5174 let mut value: Value = serde_json::from_slice(&bytes)
5175 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5176 if sanitize_ref_backed_mcp_json(&mut value) {
5177 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5178 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5179 })?;
5180 write_atomic(&backup, &sanitized)
5181 } else {
5182 std::fs::copy(&path, &backup).map(|_| ())
5183 }
5184 });
5185 if let Err(e) = backup_result {
5186 tracing::warn!("Failed to back up config.json before save: {}", e);
5187 }
5188 scrub_ref_backed_mcp_from_config_backups(&path);
5189 }
5190
5191 write_atomic(&path, content.as_bytes())
5192 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5193
5194 save_connect_config(&to_save.connect, &data_dir)?;
5195
5196 Ok(())
5197 }
5198}
5199
5200fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5205 let Some(object) = root.as_object_mut() else {
5206 return false;
5207 };
5208 let Some(mcp) = (if object.contains_key("mcpServers") {
5209 object.get_mut("mcpServers")
5210 } else {
5211 object.get_mut("mcp")
5212 }) else {
5213 return false;
5214 };
5215 let mut changed = false;
5216 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5217 for server in servers {
5218 if let Some(object) = server.as_object_mut() {
5219 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5220 {
5221 changed |= sanitize_ref_backed_mcp_transport(transport);
5222 }
5223 }
5224 }
5225 } else if let Some(servers) = mcp.as_object_mut() {
5226 for server in servers.values_mut() {
5227 if let Some(object) = server.as_object_mut() {
5228 changed |= sanitize_ref_backed_mcp_transport(object);
5229 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5230 {
5231 changed |= sanitize_ref_backed_mcp_transport(transport);
5232 }
5233 }
5234 }
5235 }
5236 changed
5237}
5238
5239fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5240 let mut changed = false;
5241 let env_names = object
5242 .get("env_credential_refs")
5243 .and_then(Value::as_object)
5244 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5245 .unwrap_or_default();
5246 for name in env_names {
5247 for field in ["env", "env_encrypted"] {
5248 if object
5249 .get_mut(field)
5250 .and_then(Value::as_object_mut)
5251 .and_then(|values| values.remove(&name))
5252 .is_some()
5253 {
5254 changed = true;
5255 }
5256 }
5257 }
5258 let header_names = object
5259 .get("header_credential_refs")
5260 .and_then(Value::as_object)
5261 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5262 .unwrap_or_default();
5263 for name in header_names {
5264 for field in ["headers", "headers_encrypted"] {
5265 if object
5266 .get_mut(field)
5267 .and_then(Value::as_object_mut)
5268 .and_then(|values| values.remove(&name))
5269 .is_some()
5270 {
5271 changed = true;
5272 }
5273 }
5274 }
5275 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5276 for header in headers {
5277 let Some(header) = header.as_object_mut() else {
5278 continue;
5279 };
5280 if header.get("credential_ref").is_some_and(Value::is_string) {
5281 changed |= header.remove("value").is_some();
5282 changed |= header.remove("value_encrypted").is_some();
5283 }
5284 }
5285 }
5286 changed
5287}
5288
5289fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5290 for generation in 0..BAK_GENERATIONS {
5291 let backup = backup_path_for(path, generation);
5292 let Ok(bytes) = std::fs::read(&backup) else {
5293 continue;
5294 };
5295 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5296 continue;
5297 };
5298 if sanitize_ref_backed_mcp_json(&mut value) {
5299 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5300 if let Err(error) = write_atomic(&backup, &sanitized) {
5301 tracing::warn!(
5302 "Failed to scrub MCP credentials from {:?}: {}",
5303 backup,
5304 error
5305 );
5306 }
5307 }
5308 }
5309 }
5310}
5311
5312fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5322 let path = data_dir.join("connect.json");
5323 if connect_config_is_empty(connect) && !path.exists() {
5324 return Ok(());
5325 }
5326
5327 if path.exists() {
5328 let backup = path.with_extension("json.bak");
5329 if let Err(e) = std::fs::copy(&path, &backup) {
5330 tracing::warn!("Failed to back up connect.json before save: {}", e);
5331 }
5332 }
5333
5334 let content = serde_json::to_string_pretty(connect)
5335 .context("Failed to serialize connect config to JSON")?;
5336 write_atomic(&path, content.as_bytes())
5337 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5338 Ok(())
5339}
5340
5341fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5359 let config_path = data_dir.join("config.json");
5360 let content = match std::fs::read_to_string(&config_path) {
5361 Ok(content) => content,
5362 Err(e) => {
5363 tracing::error!(
5364 "Failed to read config.json to strip legacy `connect` key: {}",
5365 e
5366 );
5367 return;
5368 }
5369 };
5370 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5371 Ok(value) => value,
5372 Err(e) => {
5373 tracing::error!(
5374 "Failed to parse config.json to strip legacy `connect` key: {}",
5375 e
5376 );
5377 return;
5378 }
5379 };
5380 let Some(obj) = value.as_object_mut() else {
5381 return;
5382 };
5383 if obj.remove("connect").is_none() {
5384 return;
5387 }
5388 let rewritten = match serde_json::to_string_pretty(&value) {
5389 Ok(rewritten) => rewritten,
5390 Err(e) => {
5391 tracing::error!(
5392 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5393 e
5394 );
5395 return;
5396 }
5397 };
5398 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5399 tracing::error!(
5400 "Failed to write config.json after stripping legacy `connect` key: {}",
5401 e
5402 );
5403 }
5404}
5405
5406fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5443 let config_path = data_dir.join("config.json");
5444 for gen in 0..BAK_GENERATIONS {
5445 let backup = backup_path_for(&config_path, gen);
5446 let content = match std::fs::read_to_string(&backup) {
5447 Ok(content) => content,
5448 Err(e) => {
5449 if e.kind() != std::io::ErrorKind::NotFound {
5450 tracing::warn!(
5451 "Failed to read {:?} while scanning for legacy connect data ({}); \
5452 leaving it untouched",
5453 backup,
5454 e
5455 );
5456 }
5457 continue;
5458 }
5459 };
5460 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5461 Ok(value) => value,
5462 Err(e) => {
5463 tracing::warn!(
5464 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5465 ({}); left untouched (never deleted)",
5466 backup,
5467 e
5468 );
5469 continue;
5470 }
5471 };
5472 let Some(obj) = value.as_object_mut() else {
5473 continue;
5476 };
5477 if obj.remove("connect").is_none() {
5478 continue;
5481 }
5482 let rewritten = match serde_json::to_string_pretty(&value) {
5483 Ok(rewritten) => rewritten,
5484 Err(e) => {
5485 tracing::error!(
5486 "Failed to serialize {:?} after stripping legacy connect data: {}",
5487 backup,
5488 e
5489 );
5490 continue;
5491 }
5492 };
5493 match write_atomic(&backup, rewritten.as_bytes()) {
5494 Ok(()) => tracing::info!(
5495 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5496 backup
5497 ),
5498 Err(e) => tracing::error!(
5499 "Failed to write {:?} after stripping legacy connect data: {}",
5500 backup,
5501 e
5502 ),
5503 }
5504 }
5505}
5506
5507fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5522 let backup = connect_path.with_extension("json.bak");
5523 match std::fs::rename(connect_path, &backup) {
5524 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5525 Err(e) => {
5526 tracing::warn!(
5527 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5528 backup,
5529 e
5530 );
5531 if let Err(e) = std::fs::copy(connect_path, &backup) {
5532 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5533 return;
5534 }
5535 if let Err(e) = std::fs::remove_file(connect_path) {
5536 tracing::error!(
5537 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5538 original {:?}: {}",
5539 backup,
5540 connect_path,
5541 e
5542 );
5543 }
5544 }
5545 }
5546}
5547
5548const QUARANTINE_KEEP: usize = 5;
5551
5552fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5561 let nanos = std::time::SystemTime::now()
5562 .duration_since(std::time::UNIX_EPOCH)
5563 .map(|d| d.as_nanos())
5564 .unwrap_or(0);
5565 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5569 let mut dedup = 1u32;
5570 while quarantine.exists() {
5571 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5572 dedup += 1;
5573 }
5574 let result = match std::fs::copy(config_path, &quarantine) {
5575 Ok(_) => {
5576 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5577 Some(quarantine)
5578 }
5579 Err(e) => {
5580 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5581 None
5582 }
5583 };
5584 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5585 result
5586}
5587
5588fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5591 let Some(dir) = config_path.parent() else {
5592 return;
5593 };
5594 let prefix = "config.json.corrupted.";
5595 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5596 Ok(entries) => entries
5597 .filter_map(|e| e.ok())
5598 .map(|e| e.path())
5599 .filter(|p| {
5600 p.file_name()
5601 .and_then(|n| n.to_str())
5602 .is_some_and(|n| n.starts_with(prefix))
5603 })
5604 .collect(),
5605 Err(_) => return,
5606 };
5607 if quarantines.len() <= keep {
5608 return;
5609 }
5610 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5612 let remove = quarantines.len() - keep;
5613 for stale in quarantines.into_iter().take(remove) {
5614 if let Err(e) = std::fs::remove_file(&stale) {
5615 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5616 }
5617 }
5618}
5619
5620const BAK_GENERATIONS: usize = 3;
5623
5624fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5626 if gen == 0 {
5627 config_path.with_extension("json.bak")
5628 } else {
5629 config_path.with_extension(format!("json.bak.{gen}"))
5630 }
5631}
5632
5633fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5639 for gen in (1..generations).rev() {
5640 let from = backup_path_for(config_path, gen - 1);
5641 let to = backup_path_for(config_path, gen);
5642 if from.exists() {
5643 if let Err(e) = std::fs::rename(&from, &to) {
5644 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5645 }
5646 }
5647 }
5648}
5649
5650pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5651 crate::config_store::AtomicFileStore::new(path)
5652 .write_bytes_without_backup(content)
5653 .map_err(|error| match error {
5654 crate::config_store::ConfigStoreError::Io(error) => error,
5655 other => std::io::Error::other(other),
5656 })
5657}
5658
5659#[cfg(test)]
5660mod tests {
5661 use super::*;
5662 use std::ffi::OsString;
5663 use std::path::PathBuf;
5664 use std::sync::Mutex;
5665 use std::time::{SystemTime, UNIX_EPOCH};
5666
5667 #[test]
5668 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5669 let input = serde_json::json!({
5670 "disabled": ["bash"],
5671 "plugin_runtime": {
5672 "timeout_ms": 5_000,
5673 "sandbox": true
5674 },
5675 "future_flag": "enabled"
5676 });
5677
5678 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5679 assert_eq!(config.disabled, vec!["bash"]);
5680 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5681 assert_eq!(config.extra["future_flag"], "enabled");
5682 assert_eq!(serde_json::to_value(config).unwrap(), input);
5683 }
5684
5685 #[test]
5686 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5687 let input = serde_json::json!({
5688 "tool_extension": {
5689 "mode": "strict",
5690 "options": ["one", "two"]
5691 }
5692 });
5693 let config: Config = serde_json::from_value(serde_json::json!({
5694 "tools": input.clone()
5695 }))
5696 .unwrap();
5697
5698 assert!(config.tools.disabled.is_empty());
5699 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5700 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5701
5702 let temp_home = TempHome::new();
5703 config.save_to_dir(temp_home.path.clone()).unwrap();
5704 let persisted: Value =
5705 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5706 .unwrap();
5707 assert_eq!(persisted["tools"], input);
5708 }
5709
5710 #[test]
5711 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5712 let input = serde_json::json!({
5713 "external_catalog": {
5714 "path": "/opt/bamboo/skills",
5715 "refresh": false
5716 },
5717 "schema_version": 2
5718 });
5719
5720 let config: Config = serde_json::from_value(serde_json::json!({
5721 "skills": input.clone()
5722 }))
5723 .unwrap();
5724 assert!(config.skills.disabled.is_empty());
5725 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5726 assert_eq!(config.skills.extra["schema_version"], 2);
5727 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5728
5729 let temp_home = TempHome::new();
5730 config.save_to_dir(temp_home.path.clone()).unwrap();
5731 let persisted: Value =
5732 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5733 .unwrap();
5734 assert_eq!(persisted["skills"], input);
5735 }
5736
5737 #[test]
5738 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5739 let config: Config = serde_json::from_value(serde_json::json!({
5740 "hooks": {
5741 "image_fallback": {"enabled": true, "mode": "placeholder"}
5742 },
5743 "lifecycle_hooks": {
5744 "enabled": true,
5745 "PreToolUse": [{
5746 "matcher": "bash|write_file",
5747 "hooks": [{"type": "command", "command": "guard.sh"}]
5748 }],
5749 "SessionStart": [{
5750 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5751 }]
5752 }
5753 }))
5754 .expect("lifecycle hook config should deserialize");
5755
5756 assert!(config.lifecycle_hooks.enabled);
5757 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5758 assert!(
5759 config.lifecycle_hooks.pre_tool_use[0].enabled,
5760 "legacy groups without an enabled flag remain active"
5761 );
5762 assert_eq!(
5763 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5764 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5765 );
5766 assert_eq!(
5767 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5768 25
5769 );
5770
5771 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5772 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5773 assert_eq!(
5774 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5775 "bash|write_file"
5776 );
5777 assert_eq!(
5778 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5779 "command"
5780 );
5781 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5782 .get("enabled")
5783 .is_none());
5784 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5785 .get("timeout_ms")
5786 .is_none());
5787 assert!(json.get("hooks").is_some());
5788 }
5789
5790 #[test]
5791 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5792 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5793 "type": "script",
5794 "path": ".bamboo/hooks/check.js"
5795 }))
5796 .expect("script lifecycle hook should deserialize");
5797
5798 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5799 assert!(matches!(
5800 handler,
5801 LifecycleHookHandler::Script {
5802 runner: LifecycleScriptRunner::Auto,
5803 ..
5804 }
5805 ));
5806 let json = serde_json::to_value(handler).expect("script hook should serialize");
5807 assert_eq!(json["type"], "script");
5808 assert!(json.get("timeout_ms").is_none());
5809 assert!(json.get("runner").is_none());
5810 }
5811
5812 #[test]
5813 fn script_runner_support_is_extension_aware() {
5814 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5815 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5816 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5817 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5818 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5819 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5820 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5821 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5822 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5823 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5824 }
5825
5826 #[test]
5827 fn script_runner_names_round_trip_through_config_json() {
5828 for (runner, name) in [
5829 (LifecycleScriptRunner::Auto, "auto"),
5830 (LifecycleScriptRunner::Node, "node"),
5831 (LifecycleScriptRunner::Bun, "bun"),
5832 (LifecycleScriptRunner::Python, "python"),
5833 (LifecycleScriptRunner::Bash, "bash"),
5834 (LifecycleScriptRunner::PowerShell, "powershell"),
5835 (LifecycleScriptRunner::Cmd, "cmd"),
5836 ] {
5837 let json = serde_json::to_value(runner).unwrap();
5838 assert_eq!(json, name);
5839 assert_eq!(
5840 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5841 runner
5842 );
5843 }
5844 }
5845
5846 #[test]
5847 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5848 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5849 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5850 let json = serde_json::to_value(&config).unwrap();
5851 assert!(json.get("lifecycle_hooks").is_none());
5852 }
5853
5854 #[test]
5855 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5856 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5857 .expect("legacy config without stream_timeout should load");
5858 let values = ConfigValues::from(root);
5859
5860 assert_eq!(
5861 values.stream_timeout,
5862 StreamTimeoutConfig {
5863 transport_idle_timeout_secs: 120,
5864 first_semantic_timeout_secs: 600,
5865 semantic_idle_timeout_secs: 600,
5866 }
5867 );
5868 values
5869 .stream_timeout
5870 .validate()
5871 .expect("defaults are valid");
5872 }
5873
5874 #[test]
5875 fn stream_timeout_round_trips_through_persistence_dto() {
5876 let values = ConfigValues {
5877 stream_timeout: StreamTimeoutConfig {
5878 transport_idle_timeout_secs: 45,
5879 first_semantic_timeout_secs: 900,
5880 semantic_idle_timeout_secs: 300,
5881 },
5882 ..ConfigValues::default()
5883 };
5884
5885 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5886 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5887 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5888 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5889
5890 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5891 assert_eq!(
5892 ConfigValues::from(root).stream_timeout,
5893 StreamTimeoutConfig {
5894 transport_idle_timeout_secs: 45,
5895 first_semantic_timeout_secs: 900,
5896 semantic_idle_timeout_secs: 300,
5897 }
5898 );
5899 }
5900
5901 #[test]
5902 fn context_management_defaults_to_summary_and_is_omitted() {
5903 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5904 assert_eq!(
5905 config.context_management.strategy,
5906 ContextManagementStrategy::Summary
5907 );
5908 assert_eq!(
5909 config
5910 .context_management
5911 .retrieval_window
5912 .min_recent_user_turns,
5913 3
5914 );
5915 assert_eq!(
5916 config.context_management.retrieval_target_usage_percent(),
5917 60
5918 );
5919 let json = serde_json::to_value(&config).unwrap();
5920 assert!(json.get("context_management").is_none());
5921 }
5922
5923 #[test]
5924 fn retrieval_window_context_management_round_trips() {
5925 let json = serde_json::json!({
5926 "context_management": {
5927 "strategy": "retrieval_window",
5928 "retrieval_window": {
5929 "min_recent_user_turns": 4,
5930 "trigger_usage_ratio": 0.82,
5931 "target_usage_ratio": 0.61,
5932 "history_tool_required": true,
5933 "fallback_strategy": "summary"
5934 }
5935 }
5936 });
5937 let root: ConfigRoot = serde_json::from_value(json).unwrap();
5938 let values = ConfigValues::from(root);
5939 assert_eq!(
5940 values.context_management.strategy,
5941 ContextManagementStrategy::RetrievalWindow
5942 );
5943 assert_eq!(
5944 values.context_management.retrieval_window.fallback_strategy,
5945 ContextManagementFallbackStrategy::Summary
5946 );
5947
5948 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5949 assert_eq!(
5950 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
5951 4
5952 );
5953 assert_eq!(
5954 persisted["context_management"]["strategy"],
5955 "retrieval_window"
5956 );
5957 }
5958
5959 #[test]
5960 fn message_summary_context_management_round_trips_without_changing_defaults() {
5961 let root: ConfigRoot = serde_json::from_value(serde_json::json!({
5962 "context_management": {
5963 "strategy": "message_summary"
5964 }
5965 }))
5966 .unwrap();
5967 let values = ConfigValues::from(root);
5968 assert_eq!(
5969 values.context_management.strategy,
5970 ContextManagementStrategy::MessageSummary
5971 );
5972 assert_eq!(
5973 values.context_management.retrieval_window,
5974 RetrievalWindowContextConfig::default()
5975 );
5976
5977 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5978 assert_eq!(
5979 persisted["context_management"]["strategy"],
5980 "message_summary"
5981 );
5982 let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
5983 assert_eq!(
5984 ConfigValues::from(reloaded).context_management.strategy,
5985 ContextManagementStrategy::MessageSummary
5986 );
5987 assert_eq!(
5988 ContextManagementConfig::default().strategy,
5989 ContextManagementStrategy::Summary
5990 );
5991 }
5992
5993 #[test]
5994 fn retrieval_window_context_management_rejects_unsafe_policy() {
5995 for invalid in [
5996 serde_json::json!({
5997 "strategy": "retrieval_window",
5998 "retrieval_window": {"min_recent_user_turns": 0}
5999 }),
6000 serde_json::json!({
6001 "strategy": "retrieval_window",
6002 "retrieval_window": {
6003 "target_usage_ratio": 0.8,
6004 "trigger_usage_ratio": 0.8
6005 }
6006 }),
6007 serde_json::json!({
6008 "strategy": "retrieval_window",
6009 "retrieval_window": {
6010 "target_usage_ratio": 0.005,
6011 "trigger_usage_ratio": 0.006
6012 }
6013 }),
6014 serde_json::json!({
6015 "strategy": "retrieval_window",
6016 "retrieval_window": {"history_tool_required": false}
6017 }),
6018 ] {
6019 serde_json::from_value::<ContextManagementConfig>(invalid)
6020 .expect_err("unsafe retrieval-window policy must fail closed");
6021 }
6022 }
6023
6024 #[test]
6025 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6026 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6027 "provider_type": "openai",
6028 "enabled": true
6029 }))
6030 .unwrap();
6031 let mut config = Config::default();
6032 config.values.provider = "gemini".to_string();
6033 config
6034 .provider_instances
6035 .insert("work".to_string(), instance);
6036 config.default_provider_instance = Some("work".to_string());
6037 config.providers_mut().openai = Some(OpenAIConfig::default());
6038
6039 let json = serde_json::to_value(&config).unwrap();
6040 assert_eq!(json["provider"], "gemini");
6041 assert!(json["providers"]["openai"].is_object());
6042 assert_eq!(json["default_provider_instance"], "work");
6043
6044 let round_trip: Config = serde_json::from_value(json).unwrap();
6045 assert_eq!(round_trip.provider, "gemini");
6046 assert_eq!(
6047 round_trip.default_provider_instance.as_deref(),
6048 Some("work")
6049 );
6050 assert!(round_trip.provider_instances.contains_key("work"));
6051 assert!(round_trip.providers().openai.is_some());
6052 }
6053
6054 #[test]
6055 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6056 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6057 let dir = tempfile::tempdir().unwrap();
6058 let mut config = Config::default();
6059 config.values.provider = "anthropic".to_string();
6060 config.provider_instances.insert(
6061 "work".to_string(),
6062 serde_json::from_value(serde_json::json!({
6063 "provider_type": "openai",
6064 "model": "gpt-instance",
6065 "enabled": true,
6066 "future_instance_metadata": "preserved"
6067 }))
6068 .unwrap(),
6069 );
6070 config.default_provider_instance = Some("work".to_string());
6071 config.features.provider_model_ref = true;
6072 config.providers_mut().openai = Some(OpenAIConfig::default());
6073 config.providers_mut().anthropic = Some(AnthropicConfig::default());
6074 config
6075 .providers_mut()
6076 .extra
6077 .insert("provider".to_string(), serde_json::json!("anthropic"));
6078 config.providers_mut().extra.insert(
6079 "future_provider".to_string(),
6080 serde_json::json!({"kept": true}),
6081 );
6082
6083 let (root_bytes, provider_bytes) =
6084 config.prepare_provider_transaction_documents(&[]).unwrap();
6085 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6086 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6087 assert!(root.get("provider").is_none());
6088 assert!(root.get("providers").is_none());
6089 assert_eq!(root["default_provider_instance"], "work");
6090 assert_eq!(
6091 root["provider_instances"]["work"]["future_instance_metadata"],
6092 "preserved"
6093 );
6094 assert!(providers.get("provider").is_none());
6095 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6096 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6097 }
6098 assert_eq!(providers["future_provider"]["kept"], true);
6099
6100 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6101 let saved_root: Value =
6102 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6103 .unwrap();
6104 let saved_providers: Value =
6105 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6106 .unwrap();
6107 assert!(saved_root.get("provider").is_none());
6108 assert!(saved_root.get("providers").is_none());
6109 assert_eq!(saved_root["default_provider_instance"], "work");
6110 assert!(saved_providers.get("openai").is_none());
6111 assert!(saved_providers.get("anthropic").is_none());
6112 assert!(saved_providers.get("provider").is_none());
6113 assert_eq!(saved_providers["future_provider"]["kept"], true);
6114
6115 config.providers_mut().gemini = Some(GeminiConfig::default());
6116 config.save_providers_to_dir(dir.path()).unwrap();
6117 let provider_only: Value =
6118 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6119 .unwrap();
6120 assert!(provider_only.get("gemini").is_none());
6121 assert!(provider_only.get("provider").is_none());
6122 assert_eq!(provider_only["future_provider"]["kept"], true);
6123 }
6124
6125 #[test]
6126 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6127 let mut config = Config::default();
6128 config.values.provider = "openai".to_string();
6129 config.providers_mut().openai = Some(OpenAIConfig {
6130 model: Some("gpt-legacy".to_string()),
6131 ..OpenAIConfig::default()
6132 });
6133 config.provider_instances.insert(
6134 "work".to_string(),
6135 serde_json::from_value(serde_json::json!({
6136 "provider_type": "copilot",
6137 "enabled": true
6138 }))
6139 .unwrap(),
6140 );
6141 config.default_provider_instance = Some("openai".to_string());
6142
6143 let (root_bytes, provider_bytes) =
6144 config.prepare_provider_transaction_documents(&[]).unwrap();
6145 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6146 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6147 assert_eq!(root["provider"], "openai");
6148 assert_eq!(root["default_provider_instance"], "openai");
6149 assert!(root["provider_instances"]["work"].is_object());
6150 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6151 }
6152
6153 #[test]
6154 fn persistence_keeps_legacy_provider_without_instance_default() {
6155 let values = ConfigValues {
6156 provider: "gemini".to_string(),
6157 ..ConfigValues::default()
6158 };
6159
6160 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6161 assert_eq!(json["provider"], "gemini");
6162 }
6163
6164 #[test]
6165 fn stream_timeout_rejects_zero_and_unbounded_values() {
6166 for (field, invalid) in [
6167 ("transport_idle_timeout_secs", 0),
6168 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6169 ("semantic_idle_timeout_secs", 0),
6170 ] {
6171 let mut timeout = serde_json::json!({});
6172 timeout[field] = serde_json::json!(invalid);
6173 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6174 .expect_err("invalid timeout must be rejected");
6175 assert!(error.to_string().contains("stream timeout must be between"));
6176 }
6177 }
6178
6179 struct EnvVarGuard {
6180 key: &'static str,
6181 previous: Option<OsString>,
6182 }
6183
6184 impl EnvVarGuard {
6185 fn set(key: &'static str, value: &str) -> Self {
6186 let previous = std::env::var_os(key);
6187 std::env::set_var(key, value);
6188 Self { key, previous }
6189 }
6190
6191 fn unset(key: &'static str) -> Self {
6192 let previous = std::env::var_os(key);
6193 std::env::remove_var(key);
6194 Self { key, previous }
6195 }
6196 }
6197
6198 impl Drop for EnvVarGuard {
6199 fn drop(&mut self) {
6200 match &self.previous {
6201 Some(value) => std::env::set_var(self.key, value),
6202 None => std::env::remove_var(self.key),
6203 }
6204 }
6205 }
6206
6207 #[test]
6208 fn run_budget_config_merge_is_tighten_only_per_field() {
6209 let config_default = RunBudgetConfig {
6210 max_total_tokens: Some(100_000),
6211 max_tool_calls: Some(500),
6212 max_subagents: Some(10),
6213 max_rounds: Some(200),
6214 };
6215
6216 assert_eq!(
6218 config_default.merged_with_override(None),
6219 config_default,
6220 "no override falls back to the config default entirely"
6221 );
6222
6223 let tighten_one = RunBudgetConfig {
6226 max_total_tokens: Some(5_000),
6227 max_tool_calls: None,
6228 max_subagents: None,
6229 max_rounds: None,
6230 };
6231 let merged = config_default.merged_with_override(Some(&tighten_one));
6232 assert_eq!(merged.max_total_tokens, Some(5_000));
6233 assert_eq!(merged.max_tool_calls, Some(500));
6234 assert_eq!(merged.max_subagents, Some(10));
6235 assert_eq!(merged.max_rounds, Some(200));
6236
6237 let loosen_attempt = RunBudgetConfig {
6240 max_total_tokens: Some(999_999_999),
6241 max_tool_calls: Some(10_000),
6242 max_subagents: Some(1_000),
6243 max_rounds: Some(100_000),
6244 };
6245 assert_eq!(
6246 config_default.merged_with_override(Some(&loosen_attempt)),
6247 config_default,
6248 "looser per-request values must be clamped to the config ceiling"
6249 );
6250
6251 let empty_override = RunBudgetConfig::default();
6255 assert_eq!(
6256 config_default.merged_with_override(Some(&empty_override)),
6257 config_default,
6258 "an all-absent override body keeps every configured ceiling"
6259 );
6260
6261 let unlimited_default = RunBudgetConfig::default();
6265 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6266 assert_eq!(merged.max_total_tokens, Some(5_000));
6267 assert_eq!(merged.max_tool_calls, None);
6268 assert_eq!(merged.max_subagents, None);
6269 assert_eq!(merged.max_rounds, None);
6270
6271 let tighten_rounds = RunBudgetConfig {
6274 max_total_tokens: None,
6275 max_tool_calls: None,
6276 max_subagents: None,
6277 max_rounds: Some(20),
6278 };
6279 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6280 assert_eq!(merged.max_rounds, Some(20));
6281 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6282 assert_eq!(
6283 merged.max_rounds,
6284 Some(20),
6285 "a stricter per-request round cap wins over the config default"
6286 );
6287 }
6288
6289 #[test]
6290 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6291 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6292 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6293 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6294 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6295
6296 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6297 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6298 assert_eq!(cfg.max_total_tokens, Some(250_000));
6299 assert_eq!(
6300 cfg.max_tool_calls, None,
6301 "absent field defaults to unlimited"
6302 );
6303 assert_eq!(
6304 cfg.max_rounds, None,
6305 "absent round cap defaults to unlimited"
6306 );
6307
6308 assert_eq!(cfg.max_subagents, Some(3));
6309
6310 let json = r#"{ "max_rounds": 250 }"#;
6312 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6313 assert_eq!(cfg.max_rounds, Some(250));
6314 let round_tripped: RunBudgetConfig =
6315 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6316 assert_eq!(round_tripped.max_rounds, Some(250));
6317 assert_eq!(round_tripped.max_total_tokens, None);
6318
6319 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6323 assert_eq!(empty, "{}");
6324 }
6325
6326 #[test]
6327 fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6328 let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6329 assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6330 assert!(!serde_json::to_string(&old)
6331 .unwrap()
6332 .contains("max_spawn_depth"));
6333 for depth in [0, 1, 8] {
6334 let config: SubagentsConfig =
6335 serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6336 assert_eq!(config.effective_max_spawn_depth(), depth);
6337 assert_eq!(
6338 serde_json::to_value(config).unwrap()["max_spawn_depth"],
6339 depth
6340 );
6341 }
6342 for invalid in [
6343 serde_json::json!(-1),
6344 serde_json::json!(1.5),
6345 serde_json::json!("4"),
6346 ] {
6347 assert!(serde_json::from_value::<SubagentsConfig>(
6348 serde_json::json!({"max_spawn_depth": invalid}),
6349 )
6350 .is_err());
6351 }
6352 }
6353
6354 #[test]
6355 fn subagents_config_without_remote_placements_deserializes_empty() {
6356 let json = r#"{ "max_concurrent": 4 }"#;
6359 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6360 assert_eq!(cfg.max_concurrent, Some(4));
6361 assert!(cfg.remote_placements.is_empty());
6362 let back = serde_json::to_string(&cfg).unwrap();
6364 assert!(
6365 !back.contains("remote_placements"),
6366 "empty vec is skipped: {back}"
6367 );
6368 }
6369
6370 #[test]
6371 fn remote_actor_placement_round_trips() {
6372 let json = r#"{
6373 "remote_placements": [
6374 {
6375 "role": "explorer",
6376 "endpoint": "wss://gpu-host:8443",
6377 "token_env": "WORKER_TOKEN",
6378 "ca_cert_file": "/etc/bamboo/worker.pem"
6379 },
6380 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6381 ]
6382 }"#;
6383 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6384 assert_eq!(cfg.remote_placements.len(), 2);
6385 let p0 = &cfg.remote_placements[0];
6386 assert_eq!(p0.role, "explorer");
6387 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6388 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6389 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6390 let p1 = &cfg.remote_placements[1];
6392 assert_eq!(p1.role, "writer");
6393 assert!(p1.token_env.is_none());
6394 assert!(p1.ca_cert_file.is_none());
6395
6396 let back = serde_json::to_string(&cfg).unwrap();
6397 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6398 assert_eq!(cfg, reparsed, "round-trip is stable");
6399 assert!(!back.contains("\"token_env\":null"));
6400 assert!(!back.contains("\"ca_cert_file\":null"));
6401 }
6402
6403 #[test]
6404 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6405 let json = r#"{ "max_concurrent": 4 }"#;
6408 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6409 assert!(cfg.schedulable_placements.is_empty());
6410 let back = serde_json::to_string(&cfg).unwrap();
6412 assert!(
6413 !back.contains("schedulable_placements"),
6414 "empty vec is skipped: {back}"
6415 );
6416 }
6417
6418 #[test]
6419 fn schedulable_placement_round_trips() {
6420 let json = r#"{
6421 "schedulable_placements": [
6422 {
6423 "role": "explorer",
6424 "pool": "gpu-pool",
6425 "registry_url": "https://control-plane:9562",
6426 "token_env": "WORKER_TOKEN",
6427 "ca_cert_file": "/etc/bamboo/worker.pem"
6428 },
6429 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6430 ]
6431 }"#;
6432 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6433 assert_eq!(cfg.schedulable_placements.len(), 2);
6434 let p0 = &cfg.schedulable_placements[0];
6435 assert_eq!(p0.role, "explorer");
6436 assert_eq!(p0.pool, "gpu-pool");
6437 assert_eq!(p0.registry_url, "https://control-plane:9562");
6438 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6439 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6440 let p1 = &cfg.schedulable_placements[1];
6442 assert_eq!(p1.role, "writer");
6443 assert_eq!(p1.pool, "cpu-pool");
6444 assert!(p1.token_env.is_none());
6445 assert!(p1.ca_cert_file.is_none());
6446
6447 let back = serde_json::to_string(&cfg).unwrap();
6448 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6449 assert_eq!(cfg, reparsed, "round-trip is stable");
6450 assert!(!back.contains("\"token_env\":null"));
6451 assert!(!back.contains("\"ca_cert_file\":null"));
6452 }
6453
6454 #[test]
6455 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6456 let json = r#"{ "max_concurrent": 4 }"#;
6460 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6461 assert!(cfg.mcp_role_allowlist.is_empty());
6462 let back = serde_json::to_string(&cfg).unwrap();
6464 assert!(
6465 !back.contains("mcp_role_allowlist"),
6466 "empty vec is skipped: {back}"
6467 );
6468 }
6469
6470 #[test]
6471 fn mcp_role_allowlist_entry_round_trips() {
6472 let json = r#"{
6473 "mcp_role_allowlist": [
6474 { "role": "researcher", "tools": ["fetch_url"] },
6475 { "role": "sandboxed", "tools": [] }
6476 ]
6477 }"#;
6478 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6479 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6480 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6481 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6482 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6485 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6486
6487 let back = serde_json::to_string(&cfg).unwrap();
6488 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6489 assert_eq!(cfg, reparsed, "round-trip is stable");
6490 }
6491
6492 #[test]
6493 fn server_config_without_tls_field_deserializes_back_compat() {
6494 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6497 "port": 9562,
6498 "bind": "127.0.0.1"
6499 }))
6500 .expect("legacy server config without tls should deserialize");
6501
6502 assert_eq!(server.tls, None);
6503 assert_eq!(server.port, 9562);
6504 assert_eq!(server.bind, "127.0.0.1");
6505 }
6506
6507 #[test]
6508 fn server_config_omits_tls_when_none() {
6509 let server = ServerConfig::default();
6512 let value = serde_json::to_value(&server).expect("server config should serialize");
6513 let obj = value
6514 .as_object()
6515 .expect("server config serializes to object");
6516 assert!(
6517 !obj.contains_key("tls"),
6518 "tls must be omitted when None, got: {value}"
6519 );
6520 }
6521
6522 #[test]
6523 fn server_config_with_tls_roundtrips() {
6524 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6525 "port": 9562,
6526 "bind": "0.0.0.0",
6527 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6528 }))
6529 .expect("server config with tls should deserialize");
6530
6531 let tls = server.tls.clone().expect("tls should be Some");
6532 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6533 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6534
6535 let value = serde_json::to_value(&server).expect("serialize");
6537 assert!(value.as_object().unwrap().contains_key("tls"));
6538 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6539 assert_eq!(back.tls, server.tls);
6540 }
6541
6542 #[test]
6543 fn access_control_without_devices_field_deserializes_back_compat() {
6544 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6547 "password_enabled": true,
6548 "password_hash": "deadbeef",
6549 "password_salt": "01020304",
6550 }))
6551 .expect("legacy access_control without devices should deserialize");
6552
6553 assert!(access.devices.is_empty());
6554 assert!(access.password_enabled);
6555 }
6556
6557 #[test]
6558 fn access_control_omits_devices_when_empty() {
6559 let access = AccessControlConfig {
6562 password_enabled: true,
6563 repair_required: false,
6564 password_hash: Some("deadbeef".to_string()),
6565 password_salt: Some("01020304".to_string()),
6566 password_credential_ref: None,
6567 password_configured: false,
6568 updated_at: None,
6569 devices: Vec::new(),
6570 };
6571 let value = serde_json::to_value(&access).expect("serialize");
6572 let obj = value.as_object().expect("object");
6573 assert!(
6574 !obj.contains_key("devices"),
6575 "devices must be omitted when empty, got: {value}"
6576 );
6577 }
6578
6579 #[test]
6580 fn access_control_with_devices_roundtrips() {
6581 let device = DeviceCredential {
6582 device_id: "bamboo_0123456789ab".to_string(),
6583 label: "iPhone 15".to_string(),
6584 token_hash: "abcd".to_string(),
6585 token_salt: "ef01".to_string(),
6586 token_credential_ref: None,
6587 token_configured: false,
6588 created_at: "2026-06-23T00:00:00Z".to_string(),
6589 last_used_at: None,
6590 revoked: false,
6591 };
6592 let access = AccessControlConfig {
6593 password_enabled: true,
6594 repair_required: false,
6595 password_hash: Some("deadbeef".to_string()),
6596 password_salt: Some("01020304".to_string()),
6597 password_credential_ref: None,
6598 password_configured: false,
6599 updated_at: None,
6600 devices: vec![device.clone()],
6601 };
6602 let value = serde_json::to_value(&access).expect("serialize");
6603 assert!(value.as_object().unwrap().contains_key("devices"));
6604 assert!(value["devices"][0].get("token_hash").is_none());
6605 assert!(value["devices"][0].get("token_salt").is_none());
6606 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6607 assert_eq!(back.devices[0].device_id, device.device_id);
6608 assert!(back.devices[0].token_hash.is_empty());
6609 assert!(back.devices[0].token_salt.is_empty());
6610 }
6611
6612 #[test]
6613 fn reasoning_effort_for_key_resolves_instance_id() {
6614 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6618 "provider_type": "copilot",
6619 "reasoning_effort": "high",
6620 }))
6621 .expect("instance config should deserialize");
6622
6623 let mut config = Config::create_default();
6624 config
6625 .provider_instances
6626 .insert("copilot-work".to_string(), instance);
6627
6628 assert_eq!(
6629 config.reasoning_effort_for_key("copilot-work"),
6630 Some(ReasoningEffort::High),
6631 );
6632 }
6633
6634 #[test]
6635 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6636 let mut config = Config::create_default();
6638 config.providers.bodhi = Some(
6639 serde_json::from_value(serde_json::json!({
6640 "reasoning_effort": "xhigh",
6641 }))
6642 .expect("bodhi config should deserialize"),
6643 );
6644
6645 assert_eq!(
6646 config.reasoning_effort_for_key("bodhi"),
6647 Some(ReasoningEffort::Xhigh),
6648 );
6649 }
6650
6651 #[test]
6652 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6653 let mut config = Config::create_default();
6654 config.providers.openai = Some(
6655 serde_json::from_value(serde_json::json!({
6656 "api_key": "sk-test",
6657 "reasoning_effort": "low",
6658 }))
6659 .expect("openai config should deserialize"),
6660 );
6661
6662 assert_eq!(
6663 config.reasoning_effort_for_key("openai"),
6664 Some(ReasoningEffort::Low),
6665 );
6666 }
6667
6668 #[test]
6669 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6670 let config = Config::create_default();
6671 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6672 assert_eq!(config.reasoning_effort_for_key(" "), None);
6673 }
6674
6675 struct TempHome {
6676 path: PathBuf,
6677 }
6678
6679 impl TempHome {
6680 fn new() -> Self {
6681 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6693 std::sync::atomic::AtomicU64::new(0);
6694 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6695 let nanos = SystemTime::now()
6696 .duration_since(UNIX_EPOCH)
6697 .expect("clock should be after unix epoch")
6698 .as_nanos();
6699 let path = std::env::temp_dir().join(format!(
6700 "chat-core-config-test-{}-{}-{}",
6701 std::process::id(),
6702 nanos,
6703 unique
6704 ));
6705 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6706 Self { path }
6707 }
6708
6709 fn set_config_json(&self, content: &str) {
6710 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6713 std::fs::write(self.path.join("config.json"), content)
6714 .expect("failed to write config.json");
6715 }
6716 }
6717
6718 impl Drop for TempHome {
6719 fn drop(&mut self) {
6720 let _ = std::fs::remove_dir_all(&self.path);
6721 }
6722 }
6723
6724 fn env_lock() -> &'static Mutex<()> {
6728 crate::test_support::env_cache_lock()
6729 }
6730
6731 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6733 env_lock().lock().unwrap_or_else(|poisoned| {
6734 poisoned.into_inner()
6736 })
6737 }
6738
6739 #[test]
6740 fn parse_bool_env_true_values() {
6741 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6742 assert!(parse_bool_env(value), "value {value:?} should be true");
6743 }
6744 }
6745
6746 #[test]
6747 fn parse_bool_env_false_values() {
6748 for value in ["0", "false", "no", "off", "", " "] {
6749 assert!(!parse_bool_env(value), "value {value:?} should be false");
6750 }
6751 }
6752
6753 #[test]
6754 fn config_new_ignores_http_proxy_env_vars() {
6755 let _lock = env_lock_acquire();
6756 let temp_home = TempHome::new();
6757 temp_home.set_config_json(
6758 r#"{
6759 "http_proxy": "",
6760 "https_proxy": ""
6761}"#,
6762 );
6763
6764 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6765 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6766
6767 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6768
6769 assert!(
6770 config.http_proxy.is_empty(),
6771 "config should ignore HTTP_PROXY env var"
6772 );
6773 assert!(
6774 config.https_proxy.is_empty(),
6775 "config should ignore HTTPS_PROXY env var"
6776 );
6777 }
6778
6779 #[test]
6780 fn config_new_loads_config_when_proxy_fields_omitted() {
6781 let _lock = env_lock_acquire();
6782 let temp_home = TempHome::new();
6783 temp_home.set_config_json(
6784 r#"{
6785 "provider": "openai",
6786 "providers": {
6787 "openai": {
6788 "api_key": "sk-test",
6789 "model": "gpt-4o"
6790 }
6791 }
6792}"#,
6793 );
6794
6795 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6796 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6797
6798 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6799
6800 assert_eq!(
6801 config
6802 .providers
6803 .openai
6804 .as_ref()
6805 .and_then(|c| c.model.as_deref()),
6806 Some("gpt-4o"),
6807 "config should load provider model from config file even when proxy fields are omitted"
6808 );
6809 assert!(config.http_proxy.is_empty());
6810 assert!(config.https_proxy.is_empty());
6811 }
6812
6813 #[test]
6814 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6815 let _lock = crate::test_support::env_cache_lock_acquire();
6816 let mut config = Config::default();
6817 config.env_vars.extend([
6818 EnvVarEntry {
6819 name: "SECRET_TOKEN".to_string(),
6820 value: "top-secret".to_string(),
6821 secret: true,
6822 value_encrypted: None,
6823 credential_ref: None,
6824 configured: true,
6825 description: Some("Service token".to_string()),
6826 },
6827 EnvVarEntry {
6828 name: "API_BASE".to_string(),
6829 value: "https://internal.example".to_string(),
6830 secret: false,
6831 value_encrypted: None,
6832 credential_ref: None,
6833 configured: true,
6834 description: Some("Internal API base".to_string()),
6835 },
6836 ]);
6837
6838 config.publish_env_vars();
6839
6840 let injected = Config::current_env_vars();
6841 assert_eq!(
6842 injected.get("SECRET_TOKEN").map(String::as_str),
6843 Some("top-secret")
6844 );
6845 assert_eq!(
6846 injected.get("API_BASE").map(String::as_str),
6847 Some("https://internal.example")
6848 );
6849
6850 let prompt_safe = Config::current_prompt_safe_env_vars();
6851 assert_eq!(prompt_safe.len(), 2);
6852 assert!(prompt_safe.iter().any(|entry| {
6853 entry.name == "SECRET_TOKEN"
6854 && entry.secret
6855 && entry.description.as_deref() == Some("Service token")
6856 }));
6857 assert!(prompt_safe.iter().any(|entry| {
6858 entry.name == "API_BASE"
6859 && !entry.secret
6860 && entry.description.as_deref() == Some("Internal API base")
6861 }));
6862 assert!(!prompt_safe
6863 .iter()
6864 .any(|entry| entry.name.contains("top-secret")));
6865 assert!(!prompt_safe.iter().any(|entry| {
6866 entry
6867 .description
6868 .as_deref()
6869 .is_some_and(|value| value.contains("https://internal.example"))
6870 }));
6871 }
6872
6873 #[test]
6874 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6875 let _lock = crate::test_support::env_cache_lock_acquire();
6876
6877 let mut live = Config::default();
6879 live.env_vars.extend([EnvVarEntry {
6880 name: "BAMBOO_CACHE_OWNER_40".to_string(),
6881 value: "live".to_string(),
6882 secret: false,
6883 value_encrypted: None,
6884 credential_ref: None,
6885 configured: true,
6886 description: None,
6887 }]);
6888 live.publish_env_vars();
6889 assert_eq!(
6890 Config::current_env_vars()
6891 .get("BAMBOO_CACHE_OWNER_40")
6892 .map(String::as_str),
6893 Some("live")
6894 );
6895
6896 let temp = TempHome::new();
6898 temp.set_config_json(
6899 &serde_json::json!({
6900 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6901 })
6902 .to_string(),
6903 );
6904
6905 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6908 assert_eq!(
6909 loaded
6910 .env_vars
6911 .iter()
6912 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6913 .map(|e| e.value.as_str()),
6914 Some("stale-disk"),
6915 "the returned Config holds the disk value"
6916 );
6917 assert_eq!(
6918 Config::current_env_vars()
6919 .get("BAMBOO_CACHE_OWNER_40")
6920 .map(String::as_str),
6921 Some("live"),
6922 "but the global cache is UNTOUCHED — no clobber (#40)"
6923 );
6924
6925 let _ = Config::from_data_dir(Some(temp.path.clone()));
6927 assert_eq!(
6928 Config::current_env_vars()
6929 .get("BAMBOO_CACHE_OWNER_40")
6930 .map(String::as_str),
6931 Some("stale-disk"),
6932 "the publishing loader clobbers the cache (contrast)"
6933 );
6934 }
6935
6936 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6937 std::fs::read_dir(dir)
6938 .unwrap()
6939 .filter_map(|e| e.ok())
6940 .any(|e| {
6941 e.file_name()
6942 .to_string_lossy()
6943 .contains("config.json.corrupted.")
6944 })
6945 }
6946
6947 #[test]
6948 fn corrupt_config_recovered_from_backup_and_quarantined() {
6949 let temp = TempHome::new();
6950 std::fs::write(
6952 temp.path.join("config.json.bak"),
6953 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6954 )
6955 .unwrap();
6956 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6958
6959 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6960 assert_eq!(
6961 config.http_proxy, "http://from-backup",
6962 "recovered from config.json.bak instead of losing all config"
6963 );
6964 assert!(
6965 dir_has_quarantine_file(&temp.path),
6966 "corrupt config.json was quarantined (preserved), not discarded"
6967 );
6968 }
6969
6970 #[test]
6971 fn corrupt_config_without_backup_quarantines_then_defaults() {
6972 let temp = TempHome::new();
6973 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6974
6975 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6976 assert!(
6977 config.http_proxy.is_empty(),
6978 "no backup -> falls back to defaults"
6979 );
6980 assert!(
6981 dir_has_quarantine_file(&temp.path),
6982 "corrupt config.json is quarantined even when there's no backup"
6983 );
6984 }
6985
6986 #[test]
6987 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6988 let temp = TempHome::new();
6989 temp.set_config_json(
6994 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6995 );
6996
6997 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6998 assert_eq!(
6999 config.http_proxy, "http://salvaged",
7000 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7001 );
7002 assert!(
7003 config.env_vars.is_empty(),
7004 "the corrupt field reset to its default instead of failing the whole load"
7005 );
7006 assert!(
7007 dir_has_quarantine_file(&temp.path),
7008 "the corrupt config.json was still quarantined for inspection"
7009 );
7010 }
7011
7012 #[test]
7013 fn salvage_preferred_over_backup_for_most_recent_intent() {
7014 let temp = TempHome::new();
7015 std::fs::write(
7017 temp.path.join("config.json.bak"),
7018 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7019 )
7020 .unwrap();
7021 temp.set_config_json(
7023 r#"{"http_proxy":"http://new-salvaged","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!(
7028 config.http_proxy, "http://new-salvaged",
7029 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7030 );
7031 }
7032
7033 #[test]
7034 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7035 let temp = TempHome::new();
7036 std::fs::write(
7038 temp.path.join("config.json.bak"),
7039 serde_json::json!({
7040 "http_proxy": "http://old-from-backup",
7041 "https_proxy": "https://kept-from-backup",
7042 })
7043 .to_string(),
7044 )
7045 .unwrap();
7046 temp.set_config_json(
7049 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7050 );
7051
7052 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7053 assert_eq!(
7055 config.http_proxy, "http://newer-edit",
7056 "the corrupt file's newer valid edit is applied"
7057 );
7058 assert_eq!(
7060 config.https_proxy, "https://kept-from-backup",
7061 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7062 );
7063 }
7064
7065 #[test]
7066 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7067 let temp = TempHome::new();
7068 std::fs::write(
7069 temp.path.join("config.json.bak"),
7070 serde_json::json!({
7071 "providers": {
7072 "anthropic": { "model": "claude-backup" }
7073 },
7074 "memory": {
7075 "auto_dream_enabled": true
7076 },
7077 "subagents": {
7078 "claude_code_model": "claude-code-backup"
7079 }
7080 })
7081 .to_string(),
7082 )
7083 .unwrap();
7084 assert!(!temp.path.join("providers.json").exists());
7085 assert!(!temp.path.join("memory.json").exists());
7086 assert!(!temp.path.join("subagents.json").exists());
7087
7088 let (salvaged, recovered_fields) = Config::salvage_partial(
7089 r#"{"providers":"schema-invalid-provider-section"}"#,
7090 &temp.path,
7091 )
7092 .expect("object-shaped corrupt config should be salvageable");
7093
7094 assert!(
7095 recovered_fields.is_empty(),
7096 "the schema-invalid provider field must not replace the backup baseline"
7097 );
7098 assert_eq!(
7099 salvaged
7100 .providers()
7101 .anthropic
7102 .as_ref()
7103 .and_then(|provider| provider.model.as_deref()),
7104 Some("claude-backup")
7105 );
7106 assert!(
7107 salvaged
7108 .memory()
7109 .as_ref()
7110 .expect("backup memory config survives")
7111 .auto_dream_enabled
7112 );
7113 assert_eq!(
7114 salvaged.subagents().claude_code_model.as_deref(),
7115 Some("claude-code-backup")
7116 );
7117 }
7118
7119 #[test]
7120 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7121 let temp = TempHome::new();
7122 std::fs::write(
7125 temp.path.join("config.json.bak"),
7126 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7127 )
7128 .unwrap();
7129 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7130
7131 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7132 assert_eq!(
7133 config.http_proxy, "http://from-backup",
7134 "garbage (non-object) config skips salvage and recovers from .bak"
7135 );
7136 }
7137
7138 #[test]
7139 fn quarantine_files_are_capped_to_newest_n() {
7140 let temp = TempHome::new();
7141 let config_path = temp.path.join("config.json");
7142 std::fs::write(&config_path, "{}").unwrap();
7143
7144 for _ in 0..(QUARANTINE_KEEP + 3) {
7147 quarantine_corrupt_config(&config_path);
7148 std::thread::sleep(std::time::Duration::from_millis(3));
7149 }
7150
7151 let count = std::fs::read_dir(&temp.path)
7152 .unwrap()
7153 .filter_map(|e| e.ok())
7154 .filter(|e| {
7155 e.file_name()
7156 .to_string_lossy()
7157 .starts_with("config.json.corrupted.")
7158 })
7159 .count();
7160 assert_eq!(
7161 count, QUARANTINE_KEEP,
7162 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7163 );
7164 }
7165
7166 #[test]
7167 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7168 let temp = TempHome::new();
7169 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7171 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7172 std::fs::write(
7173 temp.path.join("config.json.bak.1"),
7174 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7175 )
7176 .unwrap();
7177
7178 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7179 assert_eq!(
7180 config.http_proxy, "http://from-gen-1",
7181 "recovered from .bak.1 when both config.json and .bak are corrupt"
7182 );
7183 }
7184
7185 #[test]
7186 fn save_rotates_backup_generations() {
7187 let temp = TempHome::new();
7188 let path = temp.path.join("config.json");
7189 std::fs::write(
7191 &path,
7192 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7193 )
7194 .unwrap();
7195
7196 let mut cfg = Config::create_default();
7197 cfg.http_proxy = "http://proxy-v2".to_string();
7199 cfg.save_to_dir(temp.path.clone()).unwrap();
7200 cfg.http_proxy = "http://proxy-v3".to_string();
7202 cfg.save_to_dir(temp.path.clone()).unwrap();
7203
7204 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7205 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7206 assert!(
7207 bak.contains("proxy-v2"),
7208 ".bak holds the previous generation (v2)"
7209 );
7210 assert!(
7211 bak1.contains("proxy-v1"),
7212 ".bak.1 holds the older rotated generation (v1)"
7213 );
7214 }
7215
7216 #[test]
7217 fn save_backs_up_existing_config() {
7218 let temp = TempHome::new();
7219 std::fs::write(
7221 temp.path.join("config.json"),
7222 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7223 )
7224 .unwrap();
7225
7226 let mut config = Config::create_default();
7227 config.http_proxy = "http://new".to_string();
7228 config
7229 .save_to_dir(temp.path.clone())
7230 .expect("save succeeds");
7231
7232 let backup =
7233 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7234 assert!(
7235 backup.contains("http://old"),
7236 "config.json.bak holds the PREVIOUS config (last-known-good)"
7237 );
7238 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7239 assert!(
7240 current.contains("http://new"),
7241 "config.json holds the new config"
7242 );
7243 }
7244
7245 #[test]
7246 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7247 let temp = TempHome::new();
7248 std::fs::write(
7250 temp.path.join("config.json.bak"),
7251 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7252 )
7253 .unwrap();
7254 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7257
7258 let mut config = Config::create_default();
7259 config.http_proxy = "http://new".to_string();
7260 config
7261 .save_to_dir(temp.path.clone())
7262 .expect("save succeeds");
7263
7264 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7266 assert!(
7267 backup.contains("http://good-bak"),
7268 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7269 );
7270 }
7271
7272 #[test]
7275 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7276 let temp = TempHome::new();
7277 std::fs::write(
7278 temp.path.join("config.json.bak"),
7279 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7280 )
7281 .unwrap();
7282 let corrupt_bytes = "{ not valid json ";
7283 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7284
7285 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7286 let status = config
7287 .recovery_status()
7288 .expect("a corrupt load must set a pending recovery status");
7289 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7290 assert_eq!(
7291 status.source,
7292 ConfigRecoverySource::Backup { generation: 0 },
7293 "recovered from generation-0 (.bak)"
7294 );
7295 let quarantine_path = status
7296 .quarantine_path
7297 .as_ref()
7298 .expect("quarantine copy should have succeeded");
7299 assert_eq!(
7300 std::fs::read_to_string(quarantine_path).unwrap(),
7301 corrupt_bytes,
7302 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7303 );
7304 assert_eq!(
7305 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7306 corrupt_bytes,
7307 "the original config.json itself is untouched by the load (only copied, not moved)"
7308 );
7309 }
7310
7311 #[test]
7312 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7313 let temp = TempHome::new();
7314 temp.set_config_json(
7315 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7316 );
7317
7318 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7319 let status = config.recovery_status().expect("pending recovery");
7320 assert!(!status.confirmed);
7321 match &status.source {
7322 ConfigRecoverySource::Salvaged { fields } => {
7323 assert!(
7324 fields.iter().any(|f| f == "http_proxy"),
7325 "salvaged fields should list the recovered key: {fields:?}"
7326 );
7327 }
7328 other => panic!("expected Salvaged source, got {other:?}"),
7329 }
7330 }
7331
7332 #[test]
7333 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7334 let temp = TempHome::new();
7335 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7337
7338 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7339 let status = config.recovery_status().expect("pending recovery");
7340 assert!(!status.confirmed);
7341 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7342 }
7343
7344 #[test]
7345 fn clean_load_never_sets_recovery_status() {
7346 let temp = TempHome::new();
7347 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7348
7349 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7350 assert!(
7351 config.recovery_status().is_none(),
7352 "a config.json that parses cleanly must never carry a pending recovery status"
7353 );
7354 }
7355
7356 #[test]
7357 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7358 let temp = TempHome::new();
7359 let corrupt_bytes = "}}} broken";
7360 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7361
7362 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7363 assert!(config.recovery_status().is_some());
7364
7365 let err = config
7366 .save_to_dir(temp.path.clone())
7367 .expect_err("save must refuse while recovery is unconfirmed");
7368 assert!(
7369 err.to_string().contains("recovered from corruption")
7370 || err.to_string().contains("confirm"),
7371 "error should explain the refused overwrite: {err}"
7372 );
7373
7374 assert_eq!(
7377 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7378 corrupt_bytes,
7379 "a refused save must leave the corrupt original untouched"
7380 );
7381 }
7382
7383 #[test]
7384 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7385 let temp = TempHome::new();
7386 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7388 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7389
7390 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7391 let status = config.recovery_status().expect("pending recovery");
7392 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7393 assert_eq!(
7394 std::fs::read_to_string(quarantine_path).unwrap(),
7395 truncated,
7396 "truncated original preserved byte for byte in quarantine"
7397 );
7398
7399 let err = config.save_to_dir(temp.path.clone());
7400 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7401 assert_eq!(
7402 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7403 truncated,
7404 "the half-written original stays exactly as it was after a refused save"
7405 );
7406 }
7407
7408 #[test]
7409 fn confirm_recovery_allows_the_next_save() {
7410 let temp = TempHome::new();
7411 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7412
7413 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7414 assert!(config.recovery_status().is_some());
7415
7416 config.confirm_recovery();
7417 assert!(
7418 config.recovery_status().is_some_and(|s| s.confirmed),
7419 "confirm_recovery flips the flag but keeps the status around"
7420 );
7421
7422 config
7423 .save_to_dir(temp.path.clone())
7424 .expect("save must succeed once the recovery is confirmed");
7425 }
7426
7427 #[test]
7428 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7429 let temp = TempHome::new();
7430 std::fs::write(
7431 temp.path.join("config.json"),
7432 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7433 )
7434 .unwrap();
7435
7436 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7437 assert!(config.recovery_status().is_some());
7438 let quarantine_path = config
7439 .recovery_status()
7440 .unwrap()
7441 .quarantine_path
7442 .clone()
7443 .unwrap();
7444
7445 config
7446 .confirm_recovery_and_save_to_dir(temp.path.clone())
7447 .expect("confirm+save should succeed");
7448
7449 assert!(
7450 config.recovery_status().is_none(),
7451 "the pending flag is cleared once the recovery is confirmed and persisted"
7452 );
7453 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7454 assert!(
7455 on_disk.contains("http://recovered"),
7456 "config.json now holds the recovered (salvaged) state"
7457 );
7458 assert!(
7461 quarantine_path.exists(),
7462 "the quarantined original survives confirmation — it's never deleted"
7463 );
7464 }
7465
7466 #[test]
7467 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7468 let temp = TempHome::new();
7469 let mut config = Config::create_default();
7470 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7471 assert!(
7472 err.is_err(),
7473 "confirming a recovery that was never pending must error, not silently succeed"
7474 );
7475 }
7476
7477 fn connect_platform_with_encrypted(
7480 platform_type: &str,
7481 token_encrypted: &str,
7482 ) -> ConnectPlatformConfig {
7483 ConnectPlatformConfig {
7484 id: None,
7485 project_id: None,
7486 platform_type: platform_type.to_string(),
7487 token: None,
7488 token_encrypted: Some(token_encrypted.to_string()),
7489 token_credential_ref: None,
7490 token_configured: false,
7491 app_id: None,
7492 app_secret: None,
7493 app_secret_encrypted: None,
7494 app_secret_credential_ref: None,
7495 app_secret_configured: false,
7496 domain: None,
7497 allow_from: vec!["user-1".to_string()],
7498 admin_from: Vec::new(),
7499 }
7500 }
7501
7502 fn connect_json_path(temp: &TempHome) -> PathBuf {
7503 temp.path.join("connect.json")
7504 }
7505
7506 #[test]
7507 fn save_splits_connect_into_sibling_connect_json() {
7508 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7509 let temp = TempHome::new();
7510
7511 let mut config = Config::create_default();
7512 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7513 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7514
7515 config
7516 .save_to_dir(temp.path.clone())
7517 .expect("save succeeds");
7518
7519 let config_json: serde_json::Value =
7520 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7521 .unwrap();
7522 assert!(
7523 config_json.get("connect").is_none(),
7524 "config.json must not carry the `connect` key after a save"
7525 );
7526
7527 let connect_json: serde_json::Value =
7528 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7529 .unwrap();
7530 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7531 assert!(
7532 connect_json["platforms"][0]["token_encrypted"]
7533 .as_str()
7534 .is_some_and(|v| !v.is_empty()),
7535 "the token is persisted in its encrypted form in connect.json"
7536 );
7537 assert!(
7538 connect_json["platforms"][0].get("token").is_none(),
7539 "the plaintext token is never persisted (skip_serializing)"
7540 );
7541 }
7542
7543 #[test]
7546 fn save_assigns_a_missing_connect_platform_id() {
7547 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7548 let temp = TempHome::new();
7549
7550 let mut config = Config::create_default();
7551 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7552 assert!(
7553 config.connect.platforms[0].id.is_none(),
7554 "precondition: the entry starts without an id"
7555 );
7556
7557 config
7558 .save_to_dir(temp.path.clone())
7559 .expect("save succeeds");
7560
7561 let connect_json: serde_json::Value =
7562 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7563 .unwrap();
7564 let persisted_id = connect_json["platforms"][0]["id"]
7565 .as_str()
7566 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7567 assert!(!persisted_id.is_empty());
7568
7569 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7570 assert_eq!(
7571 reloaded.connect.platforms[0].id.as_deref(),
7572 Some(persisted_id),
7573 "the assigned id round-trips through a reload"
7574 );
7575 }
7576
7577 #[test]
7578 fn save_never_reassigns_an_existing_connect_platform_id() {
7579 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7580 let temp = TempHome::new();
7581
7582 let mut config = Config::create_default();
7583 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7584 platform.id = Some("stable-id-123".to_string());
7585 config.connect.platforms = vec![platform];
7586
7587 config
7588 .save_to_dir(temp.path.clone())
7589 .expect("first save succeeds");
7590 config
7592 .save_to_dir(temp.path.clone())
7593 .expect("second save succeeds");
7594
7595 let connect_json: serde_json::Value =
7596 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7597 .unwrap();
7598 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7599 }
7600
7601 #[test]
7602 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7603 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7604 let temp = TempHome::new();
7605
7606 let mut config = Config::create_default();
7607 config.connect.platforms = vec![
7608 connect_platform_with_encrypted("telegram", "cipher-a"),
7609 connect_platform_with_encrypted("telegram", "cipher-b"),
7610 ];
7611
7612 config
7613 .save_to_dir(temp.path.clone())
7614 .expect("save succeeds");
7615
7616 let connect_json: serde_json::Value =
7617 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7618 .unwrap();
7619 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7620 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7621 assert_ne!(
7622 id_a, id_b,
7623 "two entries sharing platform_type must still get distinct ids"
7624 );
7625 }
7626
7627 #[test]
7628 fn load_never_assigns_or_persists_an_id_by_itself() {
7629 let temp = TempHome::new();
7630 std::fs::write(
7631 connect_json_path(&temp),
7632 serde_json::json!({
7633 "platforms": [
7634 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7635 ]
7636 })
7637 .to_string(),
7638 )
7639 .unwrap();
7640 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7641
7642 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7643
7644 assert!(
7645 config.connect.platforms[0].id.is_none(),
7646 "load alone must not backfill an id in memory"
7647 );
7648 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7649 assert_eq!(
7650 connect_json_before, connect_json_after,
7651 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7652 );
7653 }
7654
7655 #[test]
7656 fn load_merges_connect_json_into_config() {
7657 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7658 let temp = TempHome::new();
7659 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7660 std::fs::write(
7661 connect_json_path(&temp),
7662 serde_json::json!({
7663 "platforms": [
7664 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7665 ]
7666 })
7667 .to_string(),
7668 )
7669 .unwrap();
7670
7671 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7672 assert_eq!(config.connect.platforms.len(), 1);
7673 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7674 assert_eq!(
7675 config.connect.platforms[0].token.as_deref(),
7676 Some("connect-secret")
7677 );
7678 assert!(config.connect.platforms[0].token_encrypted.is_none());
7679 }
7680
7681 #[test]
7682 fn load_without_connect_json_yields_empty_inert_connect_config() {
7683 let temp = TempHome::new();
7684 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7685
7686 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7687 assert!(
7688 config.connect.platforms.is_empty(),
7689 "no connect.json and no legacy key -> empty/inert connect config"
7690 );
7691 assert!(
7692 !connect_json_path(&temp).exists(),
7693 "load must not create connect.json when there is nothing to migrate"
7694 );
7695 }
7696
7697 #[test]
7698 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7699 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7700 let temp = TempHome::new();
7701 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7702 let legacy_cipher_for_assert = legacy_cipher.clone();
7703 temp.set_config_json(
7705 &serde_json::json!({
7706 "http_proxy": "http://keep-me",
7707 "connect": {
7708 "platforms": [
7709 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7710 ]
7711 }
7712 })
7713 .to_string(),
7714 );
7715
7716 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7717
7718 assert_eq!(config.connect.platforms.len(), 1);
7720 assert_eq!(
7721 config.connect.platforms[0].token.as_deref(),
7722 Some("legacy-connect-secret")
7723 );
7724 assert_eq!(config.http_proxy, "http://keep-me");
7726
7727 assert!(
7729 connect_json_path(&temp).exists(),
7730 "migration proactively creates connect.json"
7731 );
7732 let connect_json: serde_json::Value =
7733 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7734 .unwrap();
7735 assert_eq!(
7736 connect_json["platforms"][0]["token_encrypted"],
7737 legacy_cipher_for_assert
7738 );
7739
7740 let config_json: serde_json::Value =
7742 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7743 .unwrap();
7744 assert!(
7745 config_json.get("connect").is_none(),
7746 "config.json is rewritten without the legacy `connect` key"
7747 );
7748 }
7749
7750 #[test]
7758 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7759 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7760 let temp = TempHome::new();
7761
7762 let original_api_key_encrypted =
7763 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7764 temp.set_config_json(
7765 &serde_json::json!({
7766 "providers": {
7767 "openai": {
7768 "api_key_encrypted": original_api_key_encrypted,
7769 }
7770 },
7771 "connect": {
7772 "platforms": [
7773 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7774 ]
7775 }
7776 })
7777 .to_string(),
7778 );
7779
7780 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7781 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7782
7783 assert!(
7786 !temp.path.join("config.json.bak").exists(),
7787 "a read-only load migrating a legacy `connect` key must not rotate \
7788 config.json backups"
7789 );
7790
7791 let config_json: serde_json::Value =
7796 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7797 .unwrap();
7798 assert_eq!(
7799 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7800 "an unrelated secret's ciphertext must not be touched by the connect \
7801 migration's narrow write"
7802 );
7803 assert!(
7804 config_json.get("connect").is_none(),
7805 "config.json is still rewritten without the legacy `connect` key"
7806 );
7807 }
7808
7809 #[test]
7810 fn both_files_present_connect_json_wins() {
7811 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7812 let temp = TempHome::new();
7813 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7814 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7815 temp.set_config_json(
7816 &serde_json::json!({
7817 "connect": {
7818 "platforms": [
7819 { "type": "telegram", "token_encrypted": stale }
7820 ]
7821 }
7822 })
7823 .to_string(),
7824 );
7825 std::fs::write(
7826 connect_json_path(&temp),
7827 serde_json::json!({
7828 "platforms": [
7829 { "type": "telegram", "token_encrypted": authoritative }
7830 ]
7831 })
7832 .to_string(),
7833 )
7834 .unwrap();
7835
7836 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7837 assert_eq!(
7838 config.connect.platforms[0].token.as_deref(),
7839 Some("authoritative-secret"),
7840 "connect.json wins over a stale legacy config.json key"
7841 );
7842 }
7843
7844 #[test]
7849 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7850 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7851 let temp = TempHome::new();
7852 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7853 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7854 temp.set_config_json(
7855 &serde_json::json!({
7856 "http_proxy": "http://keep-me",
7857 "connect": {
7858 "platforms": [
7859 { "type": "telegram", "token_encrypted": stale }
7860 ]
7861 }
7862 })
7863 .to_string(),
7864 );
7865 std::fs::write(
7866 connect_json_path(&temp),
7867 serde_json::json!({
7868 "platforms": [
7869 { "type": "telegram", "token_encrypted": authoritative }
7870 ]
7871 })
7872 .to_string(),
7873 )
7874 .unwrap();
7875
7876 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7877 assert_eq!(
7878 config.connect.platforms[0].token.as_deref(),
7879 Some("authoritative-secret")
7880 );
7881 assert_eq!(config.http_proxy, "http://keep-me");
7883
7884 let config_json: serde_json::Value =
7885 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7886 .unwrap();
7887 assert!(
7888 config_json.get("connect").is_none(),
7889 "the stale legacy `connect` key must be stripped from config.json \
7890 immediately on load, not left for the next natural save"
7891 );
7892 }
7893
7894 #[test]
7902 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7903 let temp = TempHome::new();
7904 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7905 std::fs::write(
7906 temp.path.join("config.json.bak"),
7907 serde_json::json!({
7908 "http_proxy": "http://old",
7909 "connect": {
7910 "platforms": [
7911 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7912 ]
7913 }
7914 })
7915 .to_string(),
7916 )
7917 .unwrap();
7918
7919 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7920 assert!(config.connect.platforms.is_empty());
7923
7924 let bak: serde_json::Value = serde_json::from_str(
7925 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7926 )
7927 .unwrap();
7928 assert!(
7929 bak.get("connect").is_none(),
7930 "the legacy `connect` key must be stripped from the tainted .bak generation"
7931 );
7932 assert_eq!(
7933 bak["http_proxy"], "http://old",
7934 "every other key in the .bak generation survives the scrub byte-for-byte in content"
7935 );
7936 }
7937
7938 #[test]
7943 fn scrub_reaches_all_rotated_generations() {
7944 let temp = TempHome::new();
7945 temp.set_config_json(&serde_json::json!({}).to_string());
7946 for (gen_suffix, cipher) in [
7947 ("config.json.bak", "cipher-gen0"),
7948 ("config.json.bak.1", "cipher-gen1"),
7949 ("config.json.bak.2", "cipher-gen2"),
7950 ] {
7951 std::fs::write(
7952 temp.path.join(gen_suffix),
7953 serde_json::json!({
7954 "connect": {
7955 "platforms": [
7956 { "type": "telegram", "token_encrypted": cipher }
7957 ]
7958 }
7959 })
7960 .to_string(),
7961 )
7962 .unwrap();
7963 }
7964
7965 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7966
7967 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7968 let value: serde_json::Value =
7969 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7970 .unwrap();
7971 assert!(
7972 value.get("connect").is_none(),
7973 "{gen_suffix} must have its legacy `connect` key stripped"
7974 );
7975 }
7976 }
7977
7978 #[test]
7986 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7987 let temp = TempHome::new();
7988 temp.set_config_json(&serde_json::json!({}).to_string());
7989 let bak_path = temp.path.join("config.json.bak");
7990 std::fs::write(
7991 &bak_path,
7992 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7993 )
7994 .unwrap();
7995
7996 let before_bytes = std::fs::read(&bak_path).unwrap();
7997 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7998
7999 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8003
8004 let after_bytes = std::fs::read(&bak_path).unwrap();
8005 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8006 assert_eq!(
8007 before_bytes, after_bytes,
8008 "a .bak generation without a legacy `connect` key must not be rewritten at all"
8009 );
8010 assert_eq!(
8011 before_mtime, after_mtime,
8012 "no write means no mtime change either"
8013 );
8014 }
8015
8016 #[test]
8021 fn scrub_skips_unparsable_backup_without_deleting_it() {
8022 let temp = TempHome::new();
8023 temp.set_config_json(&serde_json::json!({}).to_string());
8024 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8025
8026 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8027
8028 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8029 assert_eq!(
8030 content, "{ not valid json",
8031 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8032 );
8033 }
8034
8035 #[test]
8039 fn scrub_tolerates_missing_generations() {
8040 let temp = TempHome::new();
8041 temp.set_config_json(&serde_json::json!({}).to_string());
8042 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8044 assert!(config.connect.platforms.is_empty());
8045 assert!(!temp.path.join("config.json.bak").exists());
8046 }
8047
8048 #[test]
8051 fn scrub_does_not_break_backup_rotation() {
8052 let temp = TempHome::new();
8053 std::fs::write(
8054 temp.path.join("config.json"),
8055 serde_json::json!({
8056 "http_proxy": "http://proxy-v1",
8057 "connect": {
8058 "platforms": [
8059 { "type": "telegram", "token_encrypted": "legacy-cipher" }
8060 ]
8061 }
8062 })
8063 .to_string(),
8064 )
8065 .unwrap();
8066 std::fs::write(
8067 temp.path.join("config.json.bak"),
8068 serde_json::json!({
8069 "http_proxy": "http://proxy-v0",
8070 "connect": {
8071 "platforms": [
8072 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8073 ]
8074 }
8075 })
8076 .to_string(),
8077 )
8078 .unwrap();
8079
8080 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8082 let bak: serde_json::Value = serde_json::from_str(
8083 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8084 )
8085 .unwrap();
8086 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8087
8088 config.http_proxy = "http://proxy-v2".to_string();
8091 config.save_to_dir(temp.path.clone()).unwrap();
8092
8093 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8094 assert!(
8095 new_bak.contains("proxy-v1"),
8096 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8097 );
8098 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8099 assert!(
8100 new_bak1.contains("proxy-v0"),
8101 ".bak.1 holds the scrubbed older generation after rotation"
8102 );
8103 assert!(
8104 !new_bak1.contains("legacy-bak-cipher"),
8105 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8106 stripped secret"
8107 );
8108 }
8109
8110 #[test]
8111 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8112 let temp = TempHome::new();
8113 temp.set_config_json("{}");
8114 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8115
8116 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8117 assert!(
8118 config.connect.platforms.is_empty(),
8119 "corrupt connect.json fails SAFE to an empty/inert connect config"
8120 );
8121
8122 let backup = connect_json_path(&temp).with_extension("json.bak");
8123 assert!(
8124 backup.exists(),
8125 "the corrupt connect.json is quarantined to connect.json.bak"
8126 );
8127 assert!(
8128 std::fs::read_to_string(backup)
8129 .unwrap()
8130 .contains("not valid json"),
8131 "the quarantined copy holds the bad content"
8132 );
8133 assert!(
8138 !connect_json_path(&temp).exists(),
8139 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8140 connect.json should remain after quarantine"
8141 );
8142 }
8143
8144 #[test]
8145 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8146 let temp = TempHome::new();
8147 temp.set_config_json(
8150 &serde_json::json!({
8151 "connect": {
8152 "platforms": [
8153 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8154 ]
8155 }
8156 })
8157 .to_string(),
8158 );
8159 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8160
8161 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8162 assert!(
8163 config.connect.platforms.is_empty(),
8164 "corrupt connect.json must not fall back to the legacy config.json copy"
8165 );
8166 }
8167
8168 #[test]
8169 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8170 let temp = TempHome::new();
8171 let config = Config::create_default();
8172 assert!(config.connect.platforms.is_empty());
8173
8174 config
8175 .save_to_dir(temp.path.clone())
8176 .expect("save succeeds");
8177
8178 assert!(
8179 !connect_json_path(&temp).exists(),
8180 "an empty connect config with no pre-existing file must not create one"
8181 );
8182 }
8183
8184 #[test]
8185 fn connect_json_backed_up_before_overwrite() {
8186 let temp = TempHome::new();
8187 std::fs::write(
8188 connect_json_path(&temp),
8189 serde_json::json!({
8190 "platforms": [
8191 { "type": "telegram", "token_encrypted": "old-cipher" }
8192 ]
8193 })
8194 .to_string(),
8195 )
8196 .unwrap();
8197
8198 let mut config = Config::create_default();
8199 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8200 config
8201 .save_to_dir(temp.path.clone())
8202 .expect("save succeeds");
8203
8204 let backup = connect_json_path(&temp).with_extension("json.bak");
8205 assert!(
8206 std::fs::read_to_string(backup)
8207 .unwrap()
8208 .contains("old-cipher"),
8209 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8210 );
8211 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8212 assert!(current.contains("new-cipher"));
8213 }
8214
8215 #[test]
8218 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8219 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8220 let temp = TempHome::new();
8221
8222 let mut config = Config::create_default();
8223 config.connect.platforms = vec![ConnectPlatformConfig {
8224 id: None,
8225 project_id: None,
8226 platform_type: "feishu".to_string(),
8227 token: None,
8228 token_encrypted: None,
8229 token_credential_ref: None,
8230 token_configured: false,
8231 app_id: Some("cli_real_app_id".to_string()),
8232 app_secret: Some("plain-app-secret".to_string()),
8233 app_secret_encrypted: None,
8234 app_secret_credential_ref: None,
8235 app_secret_configured: false,
8236 domain: Some("lark".to_string()),
8237 allow_from: vec!["ou_1".to_string()],
8238 admin_from: Vec::new(),
8239 }];
8240
8241 config
8242 .save_to_dir(temp.path.clone())
8243 .expect("save succeeds");
8244
8245 let connect_json: serde_json::Value =
8246 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8247 .unwrap();
8248 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8249 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8250 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8251 assert!(
8252 connect_json["platforms"][0]["app_secret_encrypted"]
8253 .as_str()
8254 .is_some_and(|v| !v.is_empty()),
8255 "app_secret is persisted in its encrypted form in connect.json"
8256 );
8257 assert!(
8258 connect_json["platforms"][0].get("app_secret").is_none(),
8259 "the plaintext app_secret is never persisted (skip_serializing)"
8260 );
8261 }
8262
8263 #[test]
8264 fn load_hydrates_feishu_app_secret_from_encrypted() {
8265 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8266 let temp = TempHome::new();
8267
8268 let mut config = Config::create_default();
8269 config.connect.platforms = vec![ConnectPlatformConfig {
8270 id: None,
8271 project_id: None,
8272 platform_type: "feishu".to_string(),
8273 token: None,
8274 token_encrypted: None,
8275 token_credential_ref: None,
8276 token_configured: false,
8277 app_id: Some("cli_real_app_id".to_string()),
8278 app_secret: Some("plain-app-secret".to_string()),
8279 app_secret_encrypted: None,
8280 app_secret_credential_ref: None,
8281 app_secret_configured: false,
8282 domain: Some("lark".to_string()),
8283 allow_from: vec!["ou_1".to_string()],
8284 admin_from: Vec::new(),
8285 }];
8286 config
8287 .save_to_dir(temp.path.clone())
8288 .expect("save succeeds");
8289
8290 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8291 assert_eq!(reloaded.connect.platforms.len(), 1);
8292 assert_eq!(
8293 reloaded.connect.platforms[0].app_secret.as_deref(),
8294 Some("plain-app-secret"),
8295 "reload hydrates app_secret from app_secret_encrypted"
8296 );
8297 assert_eq!(
8298 reloaded.connect.platforms[0].app_id.as_deref(),
8299 Some("cli_real_app_id")
8300 );
8301 assert_eq!(
8302 reloaded.connect.platforms[0].domain.as_deref(),
8303 Some("lark")
8304 );
8305 }
8306
8307 #[test]
8308 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8309 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8310 let temp = TempHome::new();
8311 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8312 std::fs::write(
8313 connect_json_path(&temp),
8314 serde_json::json!({
8315 "platforms": [
8316 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8317 ]
8318 })
8319 .to_string(),
8320 )
8321 .unwrap();
8322
8323 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8324
8325 assert_eq!(config.connect.platforms.len(), 1);
8326 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8327 assert_eq!(
8328 config.connect.platforms[0].token.as_deref(),
8329 Some("legacy-telegram-secret")
8330 );
8331 assert_eq!(
8332 config.connect.platforms[0].app_id, None,
8333 "a legacy entry with no Feishu fields deserializes them as None"
8334 );
8335 assert_eq!(config.connect.platforms[0].app_secret, None);
8336 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8337 assert_eq!(config.connect.platforms[0].domain, None);
8338 }
8339
8340 #[test]
8341 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8342 let _lock = env_lock_acquire();
8343 let temp_home = TempHome::new();
8344 temp_home.set_config_json(
8345 r#"{
8346 "provider": "openai",
8347 "providers": {
8348 "openai": {
8349 "api_key": "sk-test",
8350 "model": "gpt-4o"
8351 }
8352 }
8353}"#,
8354 );
8355
8356 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8357 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8358
8359 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8360
8361 assert_eq!(
8362 config
8363 .providers
8364 .openai
8365 .as_ref()
8366 .and_then(|c| c.model.as_deref()),
8367 Some("gpt-4o")
8368 );
8369 assert!(
8370 config.http_proxy.is_empty(),
8371 "config should keep http_proxy empty when field is omitted"
8372 );
8373 assert!(
8374 config.https_proxy.is_empty(),
8375 "config should keep https_proxy empty when field is omitted"
8376 );
8377 }
8378
8379 #[test]
8380 fn get_memory_background_model_prefers_memory_specific_override() {
8381 let mut config = Config::default();
8382 config.features.provider_model_ref = false;
8383 config.provider = "openai".to_string();
8384 config.providers.openai = Some(OpenAIConfig {
8385 api_key: "test".to_string(),
8386 api_key_encrypted: None,
8387 credential_ref: None,
8388 base_url: None,
8389 model: Some("gpt-main".to_string()),
8390 fast_model: Some("gpt-fast".to_string()),
8391 vision_model: None,
8392 reasoning_effort: None,
8393 responses_only_models: vec![],
8394 request_overrides: None,
8395 extra: BTreeMap::new(),
8396 api_key_from_env: false,
8397 });
8398 config.memory.0 = Some(MemoryConfig {
8399 background_model: Some("memory-fast".to_string()),
8400 ..MemoryConfig::default()
8401 });
8402
8403 assert_eq!(
8404 config.get_memory_background_model().as_deref(),
8405 Some("memory-fast")
8406 );
8407 }
8408
8409 #[test]
8410 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8411 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8416 api_key: api_key.to_string(),
8417 api_key_encrypted: None,
8418 credential_ref: None,
8419 base_url: None,
8420 model: None,
8421 fast_model: None,
8422 vision_model: None,
8423 reasoning_effort: None,
8424 responses_only_models: vec![],
8425 request_overrides: None,
8426 extra: BTreeMap::new(),
8427 api_key_from_env: from_env,
8428 };
8429
8430 let mut current = Config::default();
8432 current.providers.openai = Some(openai("sk-env", true));
8433 let mut merged = Config::default();
8434 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8436 let got = merged.providers.openai.as_ref().unwrap();
8437 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8438 assert!(got.api_key_from_env, "env flag restored");
8439
8440 let mut merged = Config::default();
8442 merged.providers.openai = Some(openai("sk-explicit", false));
8443 merged.preserve_env_sourced_provider_keys(¤t);
8444 assert_eq!(
8445 merged.providers.openai.as_ref().unwrap().api_key,
8446 "sk-explicit",
8447 "explicit patch key must win"
8448 );
8449
8450 let mut current_plain = Config::default();
8452 current_plain.providers.openai = Some(openai("sk-plain", false));
8453 let mut merged = Config::default();
8454 merged.providers.openai = Some(openai("", false));
8455 merged.preserve_env_sourced_provider_keys(¤t_plain);
8456 assert!(
8457 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8458 "non-env key must not be restored by this path"
8459 );
8460 }
8461
8462 #[test]
8463 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8464 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8468 api_key: api_key.to_string(),
8469 api_key_encrypted: enc.map(str::to_string),
8470 credential_ref: None,
8471 base_url: None,
8472 model: None,
8473 fast_model: None,
8474 vision_model: None,
8475 reasoning_effort: None,
8476 responses_only_models: vec![],
8477 request_overrides: None,
8478 extra: BTreeMap::new(),
8479 api_key_from_env: false,
8480 };
8481
8482 let mut config = Config::default();
8484 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8485 config
8486 .refresh_provider_api_keys_encrypted()
8487 .expect("refresh");
8488 assert_eq!(
8489 config
8490 .providers
8491 .openai
8492 .as_ref()
8493 .unwrap()
8494 .api_key_encrypted
8495 .as_deref(),
8496 Some("preexisting-ciphertext"),
8497 "existing ciphertext must be preserved when plaintext is empty"
8498 );
8499
8500 let mut config = Config::default();
8502 config.providers.openai = Some(openai("", None));
8503 config
8504 .refresh_provider_api_keys_encrypted()
8505 .expect("refresh");
8506 assert!(
8507 config
8508 .providers
8509 .openai
8510 .as_ref()
8511 .unwrap()
8512 .api_key_encrypted
8513 .is_none(),
8514 "no key + no ciphertext should stay None"
8515 );
8516
8517 let mut config = Config::default();
8519 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8520 config
8521 .refresh_provider_api_keys_encrypted()
8522 .expect("refresh");
8523 let enc = config
8524 .providers
8525 .openai
8526 .as_ref()
8527 .unwrap()
8528 .api_key_encrypted
8529 .clone()
8530 .expect("ciphertext present");
8531 assert!(
8532 !enc.is_empty() && enc != "stale-ciphertext",
8533 "plaintext re-encrypted"
8534 );
8535 }
8536
8537 #[test]
8538 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8539 let mut config = Config::default();
8545 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8546 "provider_type": "openai",
8547 "api_key": "sk-instance-live",
8548 }))
8549 .expect("valid instance");
8550 config
8551 .provider_instances
8552 .insert("work".to_string(), instance);
8553
8554 config.refresh_encrypted_secrets().expect("refresh");
8555 assert!(
8556 config.provider_instances["work"]
8557 .api_key_encrypted
8558 .is_some(),
8559 "live config must hold ciphertext after refresh"
8560 );
8561
8562 let value = serde_json::to_value(&config).expect("serialize");
8564 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8565 assert!(
8566 back.provider_instances["work"].api_key.is_empty(),
8567 "plaintext is skip_serializing"
8568 );
8569 back.hydrate_provider_instance_api_keys_from_encrypted();
8570 assert_eq!(
8571 back.provider_instances["work"].api_key, "sk-instance-live",
8572 "key must be recoverable from the round-tripped ciphertext"
8573 );
8574 }
8575
8576 #[test]
8577 fn get_memory_background_model_falls_back_to_provider_fast_model() {
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: "test".to_string(),
8583 api_key_encrypted: None,
8584 credential_ref: None,
8585 base_url: None,
8586 model: Some("gpt-main".to_string()),
8587 fast_model: Some("gpt-fast".to_string()),
8588 vision_model: None,
8589 reasoning_effort: None,
8590 responses_only_models: vec![],
8591 request_overrides: None,
8592 extra: BTreeMap::new(),
8593 api_key_from_env: false,
8594 });
8595
8596 assert_eq!(
8597 config.get_memory_background_model().as_deref(),
8598 Some("gpt-fast")
8599 );
8600 }
8601
8602 #[test]
8603 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8604 let mut config = Config::default();
8605 config.features.provider_model_ref = false;
8606 config.provider = "openai".to_string();
8607 config.providers.openai = Some(OpenAIConfig {
8608 api_key: "sk-stale".to_string(),
8609 model: Some("legacy-main".to_string()),
8610 fast_model: Some("legacy-fast".to_string()),
8611 vision_model: Some("legacy-vision".to_string()),
8612 reasoning_effort: Some(ReasoningEffort::Low),
8613 ..OpenAIConfig::default()
8614 });
8615 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8616 "provider_type": "openai",
8617 "model": "instance-main",
8618 "fast_model": "instance-fast",
8619 "vision_model": "instance-vision",
8620 "reasoning_effort": "high",
8621 "enabled": true
8622 }))
8623 .unwrap();
8624 instance.api_key = "sk-instance".to_string();
8625 config
8626 .provider_instances
8627 .insert("work".to_string(), instance);
8628 config.default_provider_instance = Some("work".to_string());
8629
8630 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8631 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8632 assert_eq!(
8633 config.get_memory_background_model().as_deref(),
8634 Some("instance-fast")
8635 );
8636 assert_eq!(
8637 config.get_task_summary_model().as_deref(),
8638 Some("instance-fast")
8639 );
8640 assert_eq!(
8641 config.get_vision_model().as_deref(),
8642 Some("instance-vision")
8643 );
8644 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8645 }
8646
8647 #[test]
8648 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8649 let mut config = Config::default();
8650 config.features.provider_model_ref = true;
8651 config.provider_instances.insert(
8652 "compatibility-default".to_string(),
8653 serde_json::from_value(serde_json::json!({
8654 "provider_type": "openai",
8655 "reasoning_effort": "low",
8656 "enabled": true
8657 }))
8658 .unwrap(),
8659 );
8660 config.provider_instances.insert(
8661 "chat-provider".to_string(),
8662 serde_json::from_value(serde_json::json!({
8663 "provider_type": "anthropic",
8664 "reasoning_effort": "high",
8665 "enabled": true
8666 }))
8667 .unwrap(),
8668 );
8669 config.default_provider_instance = Some("compatibility-default".to_string());
8670 config.defaults = Some(
8671 serde_json::from_value(serde_json::json!({
8672 "chat": { "provider": "chat-provider", "model": "claude" }
8673 }))
8674 .unwrap(),
8675 );
8676
8677 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8678
8679 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8680 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8681 }
8682
8683 #[test]
8684 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8685 let _provider =
8686 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8687 let _openai_key = crate::test_support::override_runtime_env_var(
8688 "BAMBOO_OPENAI_API_KEY",
8689 Some("sk-runtime-env-instance"),
8690 );
8691 let _anthropic_key =
8692 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8693 let _gemini_key =
8694 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8695
8696 let mut config = Config::default();
8697 *config.providers_mut() = ProviderConfigs::default();
8698 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8699 let mut extra = serde_json::Map::new();
8700 if from_environment {
8701 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8702 }
8703 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8704 extra.insert("enabled".to_string(), serde_json::json!(true));
8705 config.provider_instances.insert(
8706 id.to_string(),
8707 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8708 );
8709 }
8710
8711 config.apply_runtime_env_overrides();
8712
8713 assert_eq!(
8714 config.default_provider_instance.as_deref(),
8715 Some("a-marked"),
8716 "type override selects the lexicographically first enabled instance"
8717 );
8718 assert_eq!(
8719 config.provider_instances["a-marked"].api_key,
8720 "sk-runtime-env-instance"
8721 );
8722 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8723 assert!(
8724 config.providers().openai.is_none(),
8725 "instance-mode env hydration must not recreate a legacy alias"
8726 );
8727
8728 config.refresh_encrypted_secrets().unwrap();
8729 let serialized = serde_json::to_string(&config).unwrap();
8730 assert!(!serialized.contains("sk-runtime-env-instance"));
8731 assert!(
8732 config.provider_instances["a-marked"]
8733 .api_key_encrypted
8734 .is_none(),
8735 "runtime env plaintext must not be encrypted into ordinary config"
8736 );
8737 }
8738
8739 #[test]
8740 fn runtime_provider_override_prefers_exact_instance_id() {
8741 let _provider =
8742 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8743 let _openai_key =
8744 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8745 let _anthropic_key =
8746 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8747 let _gemini_key =
8748 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8749 let mut config = Config::default();
8750 for id in ["work", "personal"] {
8751 config.provider_instances.insert(
8752 id.to_string(),
8753 serde_json::from_value(serde_json::json!({
8754 "provider_type": "openai",
8755 "enabled": true
8756 }))
8757 .unwrap(),
8758 );
8759 }
8760
8761 config.apply_runtime_env_overrides();
8762 assert_eq!(
8763 config.default_provider_instance.as_deref(),
8764 Some("personal")
8765 );
8766 }
8767
8768 #[test]
8769 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
8770 let mut config = Config::default();
8771 config.features.provider_model_ref = false;
8772 config.provider = "openai".to_string();
8773 config.providers.openai = Some(OpenAIConfig {
8774 api_key: "test".to_string(),
8775 api_key_encrypted: None,
8776 credential_ref: None,
8777 base_url: None,
8778 model: Some("gpt-main".to_string()),
8779 fast_model: None,
8780 vision_model: None,
8781 reasoning_effort: None,
8782 responses_only_models: vec![],
8783 request_overrides: None,
8784 extra: BTreeMap::new(),
8785 api_key_from_env: false,
8786 });
8787
8788 assert_eq!(
8789 config.get_memory_background_model().as_deref(),
8790 Some("gpt-main")
8791 );
8792 }
8793
8794 #[test]
8795 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8796 let legacy = serde_json::json!({
8797 "memory": MemoryConfig {
8798 background_model: Some("dream-fast".to_string()),
8799 summary_target_ratio: 0.20,
8800 summary_safe_window_percent: 80,
8801 auto_dream_enabled: true,
8802 auto_dream_interval_secs: 900,
8803 project_prompt_injection: false,
8804 relevant_recall: false,
8805 relevant_recall_rerank: true,
8806 project_first_dream: false,
8807 ledger_agenda_injection: false,
8808 ledger_gardener_enabled: false,
8809 ledger_gardener_interval_secs: 7_200,
8810 ledger_distillation_enabled: false,
8811 dream_refine_mode: true,
8812 gardener_enabled: true,
8813 gardener_interval_secs: 3_600,
8814 gardener_volume_trigger: 40,
8815 gardener_max_splits_per_run: 4,
8816 gardener_min_sections: 7,
8817 dedup_gardener_enabled: true,
8818 dedup_gardener_min_score: 0.7,
8819 dedup_gardener_max_merges_per_run: 3,
8820 memory_active_capacity: 500,
8821 capacity_max_archivals_per_run: 10,
8822 granularity_freshness_gardener_enabled: false,
8823 }
8824 });
8825 let config: Config = serde_json::from_value(legacy).unwrap();
8826
8827 let serialized = serde_json::to_value(&config).expect("config should serialize");
8828 assert!(serialized.get("memory").is_some());
8829 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8830 assert!(round_tripped
8831 .memory()
8832 .as_ref()
8833 .is_some_and(|memory| memory.dream_refine_mode));
8834 let memory = config.memory.as_ref().expect("memory config should exist");
8835 assert!(memory.auto_dream_enabled);
8836 assert!(!memory.project_prompt_injection);
8837 assert!(!memory.relevant_recall);
8838 assert!(memory.relevant_recall_rerank);
8839 assert!(!memory.project_first_dream);
8840 assert!(memory.dream_refine_mode);
8841 assert!(memory.gardener_enabled);
8842 assert_eq!(memory.gardener_interval_secs, 3_600);
8843 assert_eq!(memory.gardener_volume_trigger, 40);
8844 assert_eq!(memory.gardener_max_splits_per_run, 4);
8845 assert_eq!(memory.gardener_min_sections, 7);
8846 assert!(memory.dedup_gardener_enabled);
8847 assert_eq!(memory.dedup_gardener_min_score, 0.7);
8848 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8849 assert_eq!(memory.memory_active_capacity, 500);
8850 assert_eq!(memory.capacity_max_archivals_per_run, 10);
8851 assert!(!memory.granularity_freshness_gardener_enabled);
8852 }
8853
8854 #[test]
8856 fn memory_active_capacity_defaults_off() {
8857 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8858 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8859 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8860 let memory = parsed.memory.as_ref().unwrap();
8861 assert_eq!(memory.memory_active_capacity, 0);
8862 assert_eq!(
8863 memory.capacity_max_archivals_per_run, 50,
8864 "omitted field takes the serde default fn"
8865 );
8866 }
8867
8868 #[test]
8869 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8870 let defaults = MemoryConfig::default();
8871 assert_eq!(defaults.summary_target_ratio, 0.20);
8872 assert_eq!(defaults.summary_safe_window_percent, 80);
8873
8874 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8875 let memory = parsed.memory.as_ref().expect("memory present");
8876 assert_eq!(memory.summary_target_ratio, 0.20);
8877 assert_eq!(memory.summary_safe_window_percent, 80);
8878 }
8879
8880 #[test]
8884 fn memory_maintenance_integrators_default_on() {
8885 let defaults = MemoryConfig::default();
8886 assert!(defaults.auto_dream_enabled);
8887 assert!(defaults.gardener_enabled);
8888 assert!(defaults.dedup_gardener_enabled);
8889 assert_eq!(defaults.gardener_volume_trigger, 25);
8890
8891 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8893 let memory = parsed.memory.as_ref().expect("memory present");
8894 assert!(
8895 memory.auto_dream_enabled,
8896 "auto_dream on when field omitted"
8897 );
8898 assert!(memory.gardener_enabled, "gardener on when field omitted");
8899 assert!(
8900 memory.dedup_gardener_enabled,
8901 "dedup gardener on when field omitted"
8902 );
8903 let opted_out: Config =
8905 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8906 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8907 }
8908
8909 #[test]
8910 fn memory_config_env_overrides_prompt_flags() {
8911 let _lock = env_lock_acquire();
8912 let temp_home = TempHome::new();
8913 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8914 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8915 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8916 let _relevant_recall_rerank =
8917 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8918 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8919
8920 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8921 let memory = config
8922 .memory
8923 .as_ref()
8924 .expect("memory config should be created by env overrides");
8925 assert!(!memory.project_prompt_injection);
8926 assert!(!memory.relevant_recall);
8927 assert!(memory.relevant_recall_rerank);
8928 assert!(!memory.project_first_dream);
8929 }
8930
8931 #[test]
8932 fn provider_api_keys_injected_from_env_and_never_persisted() {
8933 let _lock = env_lock_acquire();
8934 let temp_home = TempHome::new();
8935 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8936 let _anthropic = crate::test_support::override_runtime_env_var(
8937 "BAMBOO_ANTHROPIC_API_KEY",
8938 Some("sk-ant-from-env"),
8939 );
8940 let _openai = crate::test_support::override_runtime_env_var(
8941 "BAMBOO_OPENAI_API_KEY",
8942 Some("sk-oai-from-env"),
8943 );
8944 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8945
8946 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8949 assert_eq!(
8950 config
8951 .providers
8952 .anthropic
8953 .as_ref()
8954 .expect("anthropic created from env")
8955 .api_key,
8956 "sk-ant-from-env"
8957 );
8958 assert_eq!(
8959 config
8960 .providers
8961 .openai
8962 .as_ref()
8963 .expect("openai created from env")
8964 .api_key,
8965 "sk-oai-from-env"
8966 );
8967 assert!(config.providers.gemini.is_none());
8969
8970 config
8976 .save_to_dir(temp_home.path.clone())
8977 .expect("save config");
8978 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8979 .expect("read persisted config.json");
8980 assert!(
8981 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8982 "env key must not be persisted as plaintext"
8983 );
8984 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8985 assert!(
8986 disk_json["providers"]["anthropic"]
8987 .get("api_key_encrypted")
8988 .is_none(),
8989 "env-sourced anthropic key must not be re-encrypted into config.json"
8990 );
8991 assert!(
8992 disk_json["providers"]["openai"]
8993 .get("api_key_encrypted")
8994 .is_none(),
8995 "env-sourced openai key must not be re-encrypted into config.json"
8996 );
8997
8998 drop(_anthropic);
9001 drop(_openai);
9002 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9003 assert!(reloaded
9004 .providers
9005 .anthropic
9006 .as_ref()
9007 .map(|a| a.api_key.is_empty())
9008 .unwrap_or(true));
9009 }
9010
9011 #[test]
9012 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9013 let _lock = env_lock_acquire();
9014 let temp_home = TempHome::new();
9015 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9016 let target = temp_home.path.join("workspace-default");
9017 std::fs::create_dir_all(&target).expect("default work area dir should exist");
9018
9019 let mut config = Config::default();
9020 config.default_work_area.replace(DefaultWorkAreaConfig {
9021 path: Some("~/workspace-default".to_string()),
9022 });
9023
9024 assert_eq!(config.get_default_work_area_path(), Some(target));
9025 }
9026
9027 #[test]
9028 fn get_default_work_area_path_returns_none_for_missing_directory() {
9029 let _lock = env_lock_acquire();
9030 let temp_home = TempHome::new();
9031 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9032
9033 let mut config = Config::default();
9034 config.default_work_area.replace(DefaultWorkAreaConfig {
9035 path: Some("~/missing-default-work-area".to_string()),
9036 });
9037
9038 assert!(config.get_default_work_area_path().is_none());
9039 }
9040
9041 #[test]
9042 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9043 let mut config = Config::default();
9044 config.tools.disabled = vec![
9045 " read_file ".to_string(),
9046 "".to_string(),
9047 "read_file".to_string(),
9048 "bash".to_string(),
9049 "default::getCurrentDir".to_string(),
9050 "default::applyPatch".to_string(),
9051 "default::custom_tool".to_string(),
9052 "mcp__alpha__inspect".to_string(),
9053 ];
9054
9055 config.normalize_tool_settings();
9056
9057 assert_eq!(
9058 config.tools.disabled,
9059 vec![
9060 "bash",
9061 "default::applyPatch",
9062 "default::custom_tool",
9063 "default::getCurrentDir",
9064 "mcp__alpha__inspect",
9065 "read_file"
9066 ]
9067 );
9068 }
9069
9070 #[test]
9071 fn config_load_preserves_disabled_references_for_catalog_resolution() {
9072 let _lock = env_lock_acquire();
9073 let temp_home = TempHome::new();
9074 temp_home.set_config_json(
9075 r#"{
9076 "tools": {
9077 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9078 }
9079}"#,
9080 );
9081
9082 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9083 assert_eq!(
9084 config.tools.disabled,
9085 vec!["bash", "default::getCurrentDir", "read_file"]
9086 );
9087 assert!(config.disabled_tool_references().contains("bash"));
9088 assert!(config.disabled_tool_references().contains("read_file"));
9089 assert!(config
9090 .disabled_tool_references()
9091 .contains("default::getCurrentDir"));
9092 assert_eq!(
9093 config.disabled_tool_names(),
9094 BTreeSet::from([
9095 "Bash".to_string(),
9096 "GetCurrentDir".to_string(),
9097 "Read".to_string()
9098 ])
9099 );
9100 }
9101
9102 #[test]
9103 fn normalize_skill_settings_trims_dedupes_and_sorts() {
9104 let mut config = Config::default();
9105 config.skills.disabled = vec![
9106 " pdf ".to_string(),
9107 "".to_string(),
9108 "pdf".to_string(),
9109 "skill-creator".to_string(),
9110 ];
9111
9112 config.normalize_skill_settings();
9113
9114 assert_eq!(
9115 config.skills.disabled,
9116 vec!["pdf".to_string(), "skill-creator".to_string()]
9117 );
9118 }
9119
9120 #[test]
9121 fn config_load_reads_disabled_skills_as_normalized_ids() {
9122 let _lock = env_lock_acquire();
9123 let temp_home = TempHome::new();
9124 temp_home.set_config_json(
9125 r#"{
9126 "skills": {
9127 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9128 }
9129}"#,
9130 );
9131
9132 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9133 assert_eq!(
9134 config.skills.disabled,
9135 vec!["pdf".to_string(), "skill-creator".to_string()]
9136 );
9137 assert!(config.disabled_skill_ids().contains("pdf"));
9138 assert!(config.disabled_skill_ids().contains("skill-creator"));
9139 }
9140
9141 #[test]
9142 fn test_server_config_defaults() {
9143 let _lock = env_lock_acquire();
9144 let temp_home = TempHome::new();
9145
9146 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9147 assert_eq!(config.server.port, 9562);
9148 assert_eq!(config.server.bind, "127.0.0.1");
9149 assert_eq!(config.server.workers, 10);
9150 assert!(config.server.static_dir.is_none());
9151 }
9152
9153 #[test]
9154 fn test_server_addr() {
9155 let mut config = Config::default();
9156 config.server.port = 9000;
9157 config.server.bind = "0.0.0.0".to_string();
9158 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9159 }
9160
9161 #[test]
9162 fn test_env_var_overrides() {
9163 let _lock = env_lock_acquire();
9164 let temp_home = TempHome::new();
9165
9166 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9167 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9168 let _provider =
9169 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9170
9171 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9172 assert_eq!(config.server.port, 9999);
9173 assert_eq!(config.server.bind, "192.168.1.1");
9174 assert_eq!(config.provider, "openai");
9175 }
9176
9177 #[test]
9178 fn test_config_save_and_load() {
9179 let _lock = env_lock_acquire();
9180 let temp_home = TempHome::new();
9181
9182 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9183 config.server.port = 9000;
9184 config.server.bind = "0.0.0.0".to_string();
9185 config.provider = "anthropic".to_string();
9186
9187 config
9189 .save_to_dir(temp_home.path.clone())
9190 .expect("Failed to save config");
9191
9192 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9194
9195 assert_eq!(loaded.server.port, 9000);
9197 assert_eq!(loaded.server.bind, "0.0.0.0");
9198 assert_eq!(loaded.provider, "anthropic");
9199 }
9200
9201 #[test]
9202 fn modular_root_and_public_serde_preserve_legacy_shape() {
9203 let _lock = env_lock_acquire();
9204 let temp_home = TempHome::new();
9205 let input = serde_json::json!({
9206 "http_proxy": "http://proxy.example",
9207 "provider": "openai",
9208 "mcp": { "servers": [] },
9209 "future_extension": { "enabled": true },
9210 "memory": { "background_model": "memory-model" },
9211 "subagents": { "max_concurrent": 7 },
9212 "providers": { "openai": { "model": "chat-model" } }
9213 });
9214
9215 let config: Config = serde_json::from_value(input).unwrap();
9216 let public = serde_json::to_value(&config).unwrap();
9217 assert_eq!(public["http_proxy"], "http://proxy.example");
9218 assert!(public.get("mcp").is_none());
9219 assert!(public.get("mcpServers").is_some());
9220 assert_eq!(public["future_extension"]["enabled"], true);
9221 assert_eq!(public["memory"]["background_model"], "memory-model");
9222 assert_eq!(public["subagents"]["max_concurrent"], 7);
9223 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9224
9225 let round_tripped: Config = serde_json::from_value(public).unwrap();
9226 assert_eq!(
9227 round_tripped
9228 .memory()
9229 .as_ref()
9230 .unwrap()
9231 .background_model
9232 .as_deref(),
9233 Some("memory-model")
9234 );
9235 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9236 assert_eq!(
9237 round_tripped
9238 .providers()
9239 .openai
9240 .as_ref()
9241 .unwrap()
9242 .model
9243 .as_deref(),
9244 Some("chat-model")
9245 );
9246 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9247
9248 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9249 let persisted: Value =
9250 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9251 .unwrap();
9252 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9253 assert!(persisted.get("mcpServers").is_some());
9254 assert_eq!(persisted["future_extension"]["enabled"], true);
9255 assert!(persisted.get("memory").is_none());
9256 assert!(persisted.get("subagents").is_none());
9257 assert!(persisted.get("providers").is_none());
9258 for internal_section_name in [
9259 "network",
9260 "provider_routing",
9261 "model_behavior",
9262 "tooling",
9263 "workspace",
9264 "execution",
9265 "integrations",
9266 "plugin_security",
9267 ] {
9268 assert!(persisted.get(internal_section_name).is_none());
9269 }
9270 }
9271
9272 #[test]
9273 fn config_decrypts_proxy_auth_from_encrypted_field() {
9274 let _lock = env_lock_acquire();
9275 let temp_home = TempHome::new();
9276
9277 let key_guard = crate::encryption::set_test_encryption_key([
9279 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9280 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9281 0x1c, 0x1d, 0x1e, 0x1f,
9282 ]);
9283
9284 let auth = ProxyAuth {
9285 username: "user".to_string(),
9286 password: "pass".to_string(),
9287 };
9288 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9289 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9290
9291 temp_home.set_config_json(&format!(
9292 r#"{{
9293 "http_proxy": "http://proxy.example.com:8080",
9294 "proxy_auth_encrypted": "{encrypted}"
9295}}"#
9296 ));
9297 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9298 let loaded_auth = config
9299 .proxy_auth
9300 .as_ref()
9301 .expect("proxy auth should be hydrated");
9302 assert_eq!(loaded_auth.username, "user");
9303 assert_eq!(loaded_auth.password, "pass");
9304 drop(key_guard);
9305 }
9306
9307 #[test]
9308 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9309 let _lock = env_lock_acquire();
9310 let temp_home = TempHome::new();
9311
9312 let key_guard = crate::encryption::set_test_encryption_key([
9314 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9315 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9316 0x1c, 0x1d, 0x1e, 0x1f,
9317 ]);
9318
9319 let auth = ProxyAuth {
9320 username: "user".to_string(),
9321 password: "pass".to_string(),
9322 };
9323 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9324 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9325
9326 temp_home.set_config_json(&format!(
9328 r#"{{
9329 "http_proxy": "http://proxy.example.com:8080",
9330 "http_proxy_auth_encrypted": "{encrypted}",
9331 "https_proxy_auth_encrypted": "{encrypted}"
9332}}"#
9333 ));
9334
9335 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9336 let loaded_auth = config
9337 .proxy_auth
9338 .as_ref()
9339 .expect("proxy auth should be hydrated");
9340 assert_eq!(loaded_auth.username, "user");
9341 assert_eq!(loaded_auth.password, "pass");
9342 drop(key_guard);
9343 }
9344
9345 #[test]
9346 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9347 let _lock = env_lock_acquire();
9348 let temp_home = TempHome::new();
9349
9350 let key_guard = crate::encryption::set_test_encryption_key([
9352 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9353 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9354 0x1c, 0x1d, 0x1e, 0x1f,
9355 ]);
9356
9357 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9358 config.proxy_auth = Some(ProxyAuth {
9359 username: "user".to_string(),
9360 password: "pass".to_string(),
9361 });
9362 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9363 assert!(error
9364 .to_string()
9365 .contains("isolated credential transaction"));
9366 let path = temp_home.path.join("config.json");
9367 assert!(
9368 !path.exists(),
9369 "a rejected unisolated secret must not create config.json"
9370 );
9371 drop(key_guard);
9372 }
9373
9374 #[test]
9375 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9376 let _lock = env_lock_acquire();
9377 let temp_home = TempHome::new();
9378 let mut config = Config::default();
9379 config.env_vars.push(EnvVarEntry {
9380 name: "TOKEN".to_string(),
9381 value: String::new(),
9382 secret: true,
9383 value_encrypted: None,
9384 credential_ref: None,
9385 configured: true,
9386 description: None,
9387 });
9388 let path = temp_home.path.join("config.json");
9389
9390 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9391 assert!(error
9392 .to_string()
9393 .contains("isolated credential transaction"));
9394 assert!(
9395 !path.exists(),
9396 "a rejected dangling ref must not create config.json"
9397 );
9398
9399 let original = br#"{"preserve":"original"}"#;
9400 std::fs::write(&path, original).unwrap();
9401 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9402 assert_eq!(
9403 std::fs::read(&path).unwrap(),
9404 original,
9405 "a rejected dangling ref must not modify an existing config.json"
9406 );
9407 }
9408
9409 #[test]
9410 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9411 let _lock = env_lock_acquire();
9412 let temp_home = TempHome::new();
9413
9414 let key_guard = crate::encryption::set_test_encryption_key([
9416 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9417 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9418 0x1c, 0x1d, 0x1e, 0x1f,
9419 ]);
9420
9421 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9422 crate::CredentialStore::open(&temp_home.path)
9423 .replace(
9424 reference.clone(),
9425 "sk-test-provider-key",
9426 crate::CredentialSource::User,
9427 0,
9428 )
9429 .unwrap();
9430 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9431 config.provider = "openai".to_string();
9432 config.providers.openai = Some(OpenAIConfig {
9433 api_key: "sk-test-provider-key".to_string(),
9434 api_key_encrypted: None,
9435 credential_ref: Some(reference),
9436 base_url: None,
9437 model: None,
9438 fast_model: None,
9439 vision_model: None,
9440 reasoning_effort: None,
9441 responses_only_models: vec![],
9442 request_overrides: None,
9443 extra: Default::default(),
9444 api_key_from_env: false,
9445 });
9446
9447 config
9448 .save_to_dir(temp_home.path.clone())
9449 .expect("save should persist provider reference");
9450
9451 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9452 .expect("read providers.json");
9453 assert!(
9454 !content.contains("api_key_encrypted"),
9455 "providers.json must not store provider ciphertext"
9456 );
9457 assert!(
9458 !content.contains("\"api_key\""),
9459 "providers.json should not store plaintext provider keys"
9460 );
9461
9462 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9463 let openai = loaded
9464 .providers
9465 .openai
9466 .as_ref()
9467 .expect("openai config should be present");
9468 assert_eq!(openai.api_key, "sk-test-provider-key");
9469
9470 drop(key_guard);
9471 }
9472
9473 #[test]
9474 fn config_save_persists_mcp_servers_in_mainstream_format() {
9475 let _lock = env_lock_acquire();
9476 let temp_home = TempHome::new();
9477
9478 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9479
9480 let mut env = std::collections::HashMap::new();
9481 env.insert("TOKEN".to_string(), "supersecret".to_string());
9482
9483 config.mcp.servers = vec![
9484 bamboo_domain::mcp_config::McpServerConfig {
9485 id: "stdio-secret".to_string(),
9486 name: None,
9487 enabled: true,
9488 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9489 bamboo_domain::mcp_config::StdioConfig {
9490 command: "echo".to_string(),
9491 args: vec![],
9492 cwd: None,
9493 env,
9494 env_encrypted: std::collections::HashMap::new(),
9495 env_credential_refs: std::collections::HashMap::new(),
9496 startup_timeout_ms: 5000,
9497 },
9498 ),
9499 request_timeout_ms: 5000,
9500 healthcheck_interval_ms: 1000,
9501 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9502 allowed_tools: vec![],
9503 denied_tools: vec![],
9504 },
9505 bamboo_domain::mcp_config::McpServerConfig {
9506 id: "http-secret".to_string(),
9507 name: None,
9508 enabled: true,
9509 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9510 bamboo_domain::mcp_config::StreamableHttpConfig {
9511 url: "http://localhost:8080/http".to_string(),
9512 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9513 name: "Authorization".to_string(),
9514 value: "Bearer token123".to_string(),
9515 value_encrypted: None,
9516 credential_ref: None,
9517 }],
9518 connect_timeout_ms: 5000,
9519 },
9520 ),
9521 request_timeout_ms: 5000,
9522 healthcheck_interval_ms: 1000,
9523 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9524 allowed_tools: vec![],
9525 denied_tools: vec![],
9526 },
9527 ];
9528
9529 config
9530 .save_to_dir(temp_home.path.clone())
9531 .expect("save should persist MCP servers");
9532
9533 let content =
9534 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9535 assert!(
9536 content.contains("\"mcpServers\""),
9537 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9538 );
9539 assert!(
9540 content.contains("supersecret"),
9541 "config.json should persist MCP stdio env in mainstream format"
9542 );
9543 assert!(
9544 content.contains("Bearer token123"),
9545 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9546 );
9547 assert!(
9548 !content.contains("\"env_encrypted\""),
9549 "config.json should not persist legacy env_encrypted fields"
9550 );
9551 assert!(
9552 !content.contains("\"value_encrypted\""),
9553 "config.json should not persist legacy value_encrypted fields"
9554 );
9555
9556 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9557 let stdio = loaded
9558 .mcp
9559 .servers
9560 .iter()
9561 .find(|s| s.id == "stdio-secret")
9562 .expect("stdio server should exist");
9563 match &stdio.transport {
9564 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9565 assert_eq!(
9566 stdio.env.get("TOKEN").map(|s| s.as_str()),
9567 Some("supersecret")
9568 );
9569 }
9570 _ => panic!("Expected stdio transport"),
9571 }
9572
9573 let http = loaded
9574 .mcp
9575 .servers
9576 .iter()
9577 .find(|s| s.id == "http-secret")
9578 .expect("http server should exist");
9579 match &http.transport {
9580 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9581 assert_eq!(http.headers[0].value, "Bearer token123");
9582 }
9583 _ => panic!("Expected Streamable HTTP transport"),
9584 }
9585 }
9586
9587 #[test]
9588 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9589 let _lock = env_lock_acquire();
9590 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9591 let temp_home = TempHome::new();
9592 let env_plaintext = "mcp-root-env-plaintext-597";
9593 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9594 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9595 std::fs::write(
9596 temp_home.path.join("config.json"),
9597 serde_json::to_vec_pretty(&serde_json::json!({
9598 "features": {"provider_model_ref": true},
9599 "mcpServers": {
9600 "stdio-root": {
9601 "command": "unused-disabled-command",
9602 "env": {"TOKEN": env_plaintext},
9603 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9604 "env_credential_refs": {
9605 "TOKEN": "mcp.stdio-root.env_TOKEN"
9606 }
9607 },
9608 "http-root": {
9609 "url": "https://example.test/mcp",
9610 "transport_kind": "streamable_http",
9611 "headers": {"Authorization": header_plaintext},
9612 "header_credential_refs": {
9613 "Authorization": "mcp.http-root.header_Authorization"
9614 }
9615 }
9616 }
9617 }))
9618 .unwrap(),
9619 )
9620 .unwrap();
9621 std::fs::write(
9622 temp_home.path.join("mcp.json"),
9623 serde_json::to_vec_pretty(&serde_json::json!({
9624 "schema_version": 1,
9625 "revision": 7,
9626 "data": {
9627 "stdio-root": {
9628 "command": "unused-disabled-command",
9629 "enabled": false,
9630 "env_encrypted": {"TOKEN": legacy_ciphertext},
9631 "request_timeout_ms": 100,
9632 "healthcheck_interval_ms": 100
9633 },
9634 "http-root": {
9635 "url": "https://example.test/mcp",
9636 "transport_kind": "streamable_http",
9637 "enabled": false,
9638 "headers": {"Authorization": header_plaintext},
9639 "request_timeout_ms": 100,
9640 "healthcheck_interval_ms": 100
9641 }
9642 }
9643 }))
9644 .unwrap(),
9645 )
9646 .unwrap();
9647
9648 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9649 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9650 temp_home.path.join("mcp.json"),
9651 1,
9652 )
9653 .load()
9654 .unwrap()
9655 .unwrap();
9656 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9657 config.mcp = stored.data;
9658 config
9659 .hydrate_mcp_credentials_from_store(&temp_home.path)
9660 .unwrap();
9661
9662 let public = serde_json::to_value(&config.mcp).unwrap();
9663 let rendered_public = public.to_string();
9664 assert!(rendered_public.contains(env_plaintext));
9665 assert!(rendered_public.contains(header_plaintext));
9666 let compatible: bamboo_domain::mcp_config::McpConfig =
9667 serde_json::from_value(public).unwrap();
9668 assert_eq!(compatible.servers.len(), 2);
9669
9670 config.features.provider_model_ref = !config.features.provider_model_ref;
9671 config.save_to_dir(temp_home.path.clone()).unwrap();
9672 config.features.provider_model_ref = !config.features.provider_model_ref;
9673 config.save_to_dir(temp_home.path.clone()).unwrap();
9674
9675 for entry in std::fs::read_dir(&temp_home.path)
9676 .unwrap()
9677 .filter_map(Result::ok)
9678 {
9679 let name = entry.file_name().to_string_lossy().to_string();
9680 if name == "credentials.json" || entry.path().is_dir() {
9681 continue;
9682 }
9683 if name == "config.json"
9684 || name == "mcp.json"
9685 || name.contains(".bak")
9686 || name.starts_with("config-credential-migration")
9687 {
9688 let bytes = std::fs::read(entry.path()).unwrap();
9689 let content = String::from_utf8_lossy(&bytes);
9690 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9691 assert!(
9692 !content.contains(header_plaintext),
9693 "secret leaked to {name}"
9694 );
9695 assert!(
9696 !content.contains(&legacy_ciphertext),
9697 "legacy ciphertext leaked to {name}"
9698 );
9699 }
9700 }
9701 }
9702
9703 #[test]
9706 fn env_vars_as_map_includes_only_non_empty_values() {
9707 let mut config = Config::default();
9708 config.env_vars.extend([
9709 EnvVarEntry {
9710 name: "A".to_string(),
9711 value: "val_a".to_string(),
9712 secret: false,
9713 value_encrypted: None,
9714 credential_ref: None,
9715 configured: true,
9716 description: None,
9717 },
9718 EnvVarEntry {
9719 name: "B".to_string(),
9720 value: "".to_string(), secret: true,
9722 value_encrypted: None,
9723 credential_ref: None,
9724 configured: false,
9725 description: None,
9726 },
9727 EnvVarEntry {
9728 name: "C".to_string(),
9729 value: " ".to_string(), secret: false,
9731 value_encrypted: None,
9732 credential_ref: None,
9733 configured: true,
9734 description: None,
9735 },
9736 EnvVarEntry {
9737 name: "D".to_string(),
9738 value: "val_d".to_string(),
9739 secret: true,
9740 value_encrypted: Some("enc".to_string()),
9741 credential_ref: None,
9742 configured: true,
9743 description: Some("desc".to_string()),
9744 },
9745 ]);
9746
9747 let map = config.env_vars_as_map();
9748 assert_eq!(map.len(), 2);
9749 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9750 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9751 assert!(!map.contains_key("B"));
9752 assert!(!map.contains_key("C"));
9753 }
9754
9755 #[test]
9756 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9757 let mut config = Config::default();
9758 config.env_vars.extend([
9759 EnvVarEntry {
9760 name: "PLAIN".to_string(),
9761 value: "visible".to_string(),
9762 secret: false,
9763 value_encrypted: None,
9764 credential_ref: None,
9765 configured: true,
9766 description: None,
9767 },
9768 EnvVarEntry {
9769 name: "SECRET".to_string(),
9770 value: "hidden_value".to_string(),
9771 secret: true,
9772 value_encrypted: Some("enc_data".to_string()),
9773 credential_ref: None,
9774 configured: true,
9775 description: None,
9776 },
9777 ]);
9778
9779 config.sanitize_env_vars_for_disk();
9780
9781 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
9784
9785 #[test]
9786 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9787 let mut config = Config::default();
9788 config.env_vars.extend([
9789 EnvVarEntry {
9790 name: "OPEN".to_string(),
9791 value: "val".to_string(),
9792 secret: false,
9793 value_encrypted: None,
9794 credential_ref: None,
9795 configured: true,
9796 description: None,
9797 },
9798 EnvVarEntry {
9799 name: "HIDDEN".to_string(),
9800 value: "real_secret".to_string(),
9801 secret: true,
9802 value_encrypted: Some("enc".to_string()),
9803 credential_ref: None,
9804 configured: true,
9805 description: None,
9806 },
9807 ]);
9808
9809 config.sanitize_env_vars_for_disk();
9810
9811 assert_eq!(config.env_vars[0].value, "val");
9813 assert_eq!(config.env_vars[1].value, "");
9815 assert!(config.env_vars[1].value_encrypted.is_none());
9816 }
9817
9818 #[test]
9819 fn legacy_env_ciphertext_is_read_but_never_serialized() {
9820 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9821 let mut config: Config = serde_json::from_value(serde_json::json!({
9822 "env_vars": [{
9823 "name": "TOKEN",
9824 "secret": true,
9825 "value_encrypted": ciphertext
9826 }]
9827 }))
9828 .unwrap();
9829 config.hydrate_env_vars_from_encrypted();
9830 assert_eq!(config.env_vars[0].value, "my-secret-token");
9831 let serialized = serde_json::to_value(&config).unwrap();
9832 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9833 }
9834
9835 #[test]
9836 fn configured_env_ref_missing_from_store_fails_closed() {
9837 let dir = tempfile::tempdir().unwrap();
9838 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9839 let mut config = Config::default();
9840 config.env_vars.push(EnvVarEntry {
9841 name: "TOKEN".to_string(),
9842 value: String::new(),
9843 secret: true,
9844 value_encrypted: None,
9845 credential_ref: Some(reference),
9846 configured: true,
9847 description: None,
9848 });
9849 let error = config
9850 .hydrate_env_var_credentials_from_store(dir.path())
9851 .unwrap_err();
9852 assert!(error
9853 .to_string()
9854 .contains("referenced env credential is unavailable"));
9855 assert!(config.env_vars[0].configured);
9856 assert!(config.env_vars[0].value.is_empty());
9857 }
9858
9859 #[test]
9860 fn configured_notification_ref_missing_from_store_fails_closed() {
9861 let dir = tempfile::tempdir().unwrap();
9862 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9863 let mut config = Config::default();
9864 config.notifications.ntfy.credential_ref = Some(reference);
9865 config.notifications.ntfy.configured = true;
9866
9867 let error = config
9868 .hydrate_notification_credentials_from_store(dir.path())
9869 .unwrap_err();
9870
9871 assert!(error
9872 .to_string()
9873 .contains("referenced ntfy credential is unavailable"));
9874 assert!(config.notifications.ntfy.configured);
9875 assert!(config.notifications.ntfy.token.is_none());
9876 }
9877
9878 #[test]
9879 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9880 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9881 let dir = tempfile::tempdir().unwrap();
9882 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9883 crate::CredentialStore::open(dir.path())
9884 .replace(
9885 reference.clone(),
9886 "exclusive-notification-secret",
9887 crate::CredentialSource::User,
9888 0,
9889 )
9890 .unwrap();
9891
9892 let mut config = Config::default();
9893 config.notifications.ntfy.credential_ref = Some(reference.clone());
9894 config.notifications.ntfy.configured = true;
9895 config.proxy_auth_credential_ref = Some(reference.clone());
9896 let error = config
9897 .hydrate_notification_credentials_from_store(dir.path())
9898 .unwrap_err();
9899 let rendered = error.to_string();
9900 assert!(rendered
9901 .contains("notification credential reference is shared by another config consumer"));
9902 assert!(!rendered.contains(reference.as_str()));
9903 assert!(config.notifications.ntfy.token.is_none());
9904
9905 config.proxy_auth_credential_ref = None;
9906 config
9907 .hydrate_notification_credentials_from_store(dir.path())
9908 .unwrap();
9909 assert_eq!(
9910 config.notifications.ntfy.token.as_deref(),
9911 Some("exclusive-notification-secret")
9912 );
9913 }
9914
9915 #[test]
9916 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9917 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9918 let dir = tempfile::tempdir().unwrap();
9919 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9920 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9921 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9922 "notifications": {
9923 "ntfy": { "enabled": true, "token_encrypted": ntfy },
9924 "bark": { "enabled": true, "device_key_encrypted": bark }
9925 }
9926 }))
9927 .unwrap();
9928 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9929 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9930
9931 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9932
9933 assert!(loaded.notifications.ntfy.token.is_none());
9934 assert!(loaded.notifications.bark.device_key.is_none());
9935 assert_eq!(
9936 std::fs::read(dir.path().join("config.json")).unwrap(),
9937 bytes
9938 );
9939 }
9940
9941 #[test]
9942 fn publish_and_current_env_vars_round_trip() {
9943 let _lock = crate::test_support::env_cache_lock_acquire();
9953 let mut config = Config::default();
9954 config.env_vars.extend([EnvVarEntry {
9955 name: "TEST_PUBLISH".to_string(),
9956 value: "pub_value".to_string(),
9957 secret: false,
9958 value_encrypted: None,
9959 credential_ref: None,
9960 configured: true,
9961 description: None,
9962 }]);
9963
9964 config.publish_env_vars();
9965 assert_eq!(
9966 Config::current_env_vars()
9967 .get("TEST_PUBLISH")
9968 .map(String::as_str),
9969 Some("pub_value")
9970 );
9971 }
9972
9973 #[test]
9974 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9975 let mut config = Config::default();
9976 config.subagents.broker = Some(BrokerClientConfig {
9977 endpoint: "ws://127.0.0.1:9600".to_string(),
9978 token: "super-secret-token".to_string(),
9979 token_encrypted: None,
9980 credential_ref: None,
9981 configured: false,
9982 });
9983
9984 config.refresh_broker_token_encrypted().unwrap();
9986 config.sanitize_broker_token_for_disk();
9987 let broker = config.subagents.broker.as_ref().unwrap();
9988 assert!(broker.token.is_empty(), "plaintext cleared for disk");
9989 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9990 assert_ne!(
9991 broker.token_encrypted.as_deref(),
9992 Some("super-secret-token")
9993 );
9994
9995 config.hydrate_broker_token_from_encrypted();
9997 assert_eq!(
9998 config.subagents.broker.as_ref().unwrap().token,
9999 "super-secret-token"
10000 );
10001 }
10002
10003 #[test]
10004 fn broker_token_empty_refresh_preserves_ciphertext() {
10005 let mut config = Config::default();
10007 config.subagents.broker = Some(BrokerClientConfig {
10008 endpoint: "ws://h:9600".to_string(),
10009 token: String::new(),
10010 token_encrypted: Some("existing-cipher".to_string()),
10011 credential_ref: None,
10012 configured: false,
10013 });
10014 config.refresh_broker_token_encrypted().unwrap();
10015 assert_eq!(
10016 config
10017 .subagents
10018 .broker
10019 .as_ref()
10020 .unwrap()
10021 .token_encrypted
10022 .as_deref(),
10023 Some("existing-cipher"),
10024 );
10025 }
10026
10027 #[test]
10028 fn notifications_config_defaults_when_key_missing() {
10029 let config: Config = serde_json::from_str("{}").expect("empty object parses");
10032 assert_eq!(config.notifications, NotificationsConfig::default());
10033 assert_eq!(config.notifications.desktop.enabled, None);
10034 assert!(!config.notifications.ntfy.enabled);
10035 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10036 assert_eq!(config.notifications.ntfy.token, None);
10037 assert!(!config.notifications.bark.enabled);
10038 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10039 assert_eq!(config.notifications.bark.device_key, None);
10040 }
10041
10042 #[test]
10043 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10044 let mut config = Config::default();
10045 config.notifications.ntfy = NtfyChannelConfig {
10046 enabled: true,
10047 base_url: "https://ntfy.sh".to_string(),
10048 topic: "bamboo-alerts".to_string(),
10049 token: Some("tk_super_secret".to_string()),
10050 token_encrypted: None,
10051 credential_ref: None,
10052 configured: false,
10053 };
10054
10055 config.refresh_notifications_encrypted().unwrap();
10058 assert!(config.notifications.ntfy.token_encrypted.is_some());
10059 assert_ne!(
10060 config.notifications.ntfy.token_encrypted.as_deref(),
10061 Some("tk_super_secret")
10062 );
10063
10064 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10066 assert!(
10067 !json.contains("tk_super_secret"),
10068 "plaintext token must never be serialized"
10069 );
10070 assert!(!json.contains("token_encrypted"));
10071
10072 config.notifications.ntfy.token = None;
10074 config.hydrate_notifications_from_encrypted();
10075 assert_eq!(
10076 config.notifications.ntfy.token.as_deref(),
10077 Some("tk_super_secret")
10078 );
10079 }
10080
10081 #[test]
10082 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10083 let mut config = Config::default();
10084 config.notifications.bark = BarkChannelConfig {
10085 enabled: true,
10086 base_url: "https://api.day.app".to_string(),
10087 device_key: Some("dk_super_secret".to_string()),
10088 device_key_encrypted: None,
10089 credential_ref: None,
10090 configured: false,
10091 };
10092
10093 config.refresh_notifications_encrypted().unwrap();
10094 assert!(config.notifications.bark.device_key_encrypted.is_some());
10095 assert_ne!(
10096 config.notifications.bark.device_key_encrypted.as_deref(),
10097 Some("dk_super_secret")
10098 );
10099
10100 let json = serde_json::to_string(&config.notifications.bark).unwrap();
10101 assert!(
10102 !json.contains("dk_super_secret"),
10103 "plaintext device key must never be serialized"
10104 );
10105 assert!(!json.contains("device_key_encrypted"));
10106
10107 config.notifications.bark.device_key = None;
10108 config.hydrate_notifications_from_encrypted();
10109 assert_eq!(
10110 config.notifications.bark.device_key.as_deref(),
10111 Some("dk_super_secret")
10112 );
10113 }
10114
10115 #[test]
10116 fn notification_secrets_empty_refresh_preserves_ciphertext() {
10117 let mut config = Config::default();
10120 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10121 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10122
10123 config.refresh_notifications_encrypted().unwrap();
10124
10125 assert_eq!(
10126 config.notifications.ntfy.token_encrypted.as_deref(),
10127 Some("existing-ntfy-cipher")
10128 );
10129 assert_eq!(
10130 config.notifications.bark.device_key_encrypted.as_deref(),
10131 Some("existing-bark-cipher")
10132 );
10133 }
10134
10135 #[test]
10136 fn hydrate_skips_non_secret_entries() {
10137 let mut config = Config::default();
10138 config.env_vars.extend([EnvVarEntry {
10139 name: "PLAIN".to_string(),
10140 value: "original".to_string(),
10141 secret: false,
10142 value_encrypted: Some("should-be-ignored".to_string()),
10143 credential_ref: None,
10144 configured: true,
10145 description: None,
10146 }]);
10147
10148 config.hydrate_env_vars_from_encrypted();
10149 assert_eq!(config.env_vars[0].value, "original");
10151 }
10152
10153 #[test]
10154 fn default_config_has_empty_env_vars() {
10155 assert!(Config::default().env_vars.is_empty());
10160 }
10161
10162 #[test]
10163 fn serde_round_trip_with_env_vars() {
10164 let mut config = Config::default();
10165 config.env_vars.extend([
10166 EnvVarEntry {
10167 name: "KEY1".to_string(),
10168 value: "val1".to_string(),
10169 secret: false,
10170 value_encrypted: None,
10171 credential_ref: None,
10172 configured: true,
10173 description: Some("First key".to_string()),
10174 },
10175 EnvVarEntry {
10176 name: "KEY2".to_string(),
10177 value: "".to_string(), secret: true,
10179 value_encrypted: Some("enc123".to_string()),
10180 credential_ref: None,
10181 configured: true,
10182 description: None,
10183 },
10184 ]);
10185
10186 let json = serde_json::to_string(&config).unwrap();
10187 let restored: Config = serde_json::from_str(&json).unwrap();
10188
10189 assert_eq!(restored.env_vars.len(), 2);
10190 assert_eq!(restored.env_vars[0].name, "KEY1");
10191 assert_eq!(restored.env_vars[0].value, "val1");
10192 assert!(!restored.env_vars[0].secret);
10193 assert_eq!(restored.env_vars[1].name, "KEY2");
10194 assert!(restored.env_vars[1].secret);
10195 assert!(restored.env_vars[1].value_encrypted.is_none());
10196 }
10197
10198 #[test]
10201 #[allow(clippy::field_reassign_with_default)]
10203 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10204 let mut config = Config::default();
10205 config.provider = "openai".to_string();
10206 config.providers.openai = Some(OpenAIConfig {
10207 api_key: "test".to_string(),
10208 api_key_encrypted: None,
10209 credential_ref: None,
10210 base_url: None,
10211 model: Some("legacy-gpt-4o".to_string()),
10212 fast_model: None,
10213 vision_model: None,
10214 reasoning_effort: None,
10215 responses_only_models: vec![],
10216 request_overrides: None,
10217 extra: Default::default(),
10218 api_key_from_env: false,
10219 });
10220 config.features.provider_model_ref = true;
10221 config.defaults = Some(DefaultsConfig {
10222 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10223 fast: None,
10224 task_summary: None,
10225 vision: None,
10226 memory_background: None,
10227 planning: None,
10228 search: None,
10229 code_review: None,
10230 sub_agent: None,
10231 subagent_models: Default::default(),
10232 });
10233
10234 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10235 }
10236
10237 #[test]
10238 #[allow(clippy::field_reassign_with_default)]
10240 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10241 let mut config = Config::default();
10242 config.provider = "openai".to_string();
10243 config.providers.openai = Some(OpenAIConfig {
10244 api_key: "test".to_string(),
10245 api_key_encrypted: None,
10246 credential_ref: None,
10247 base_url: None,
10248 model: Some("legacy-gpt-4o".to_string()),
10249 fast_model: None,
10250 vision_model: None,
10251 reasoning_effort: None,
10252 responses_only_models: vec![],
10253 request_overrides: None,
10254 extra: Default::default(),
10255 api_key_from_env: false,
10256 });
10257 config.features.provider_model_ref = false;
10258 config.defaults = Some(DefaultsConfig {
10259 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10260 fast: None,
10261 task_summary: None,
10262 vision: None,
10263 memory_background: None,
10264 planning: None,
10265 search: None,
10266 code_review: None,
10267 sub_agent: None,
10268 subagent_models: Default::default(),
10269 });
10270
10271 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10272 }
10273
10274 #[test]
10275 #[allow(clippy::field_reassign_with_default)]
10277 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10278 let mut config = Config::default();
10279 config.provider = "openai".to_string();
10280 config.providers.openai = Some(OpenAIConfig {
10281 api_key: "test".to_string(),
10282 api_key_encrypted: None,
10283 credential_ref: None,
10284 base_url: None,
10285 model: Some("gpt-4o".to_string()),
10286 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10287 vision_model: None,
10288 reasoning_effort: None,
10289 responses_only_models: vec![],
10290 request_overrides: None,
10291 extra: Default::default(),
10292 api_key_from_env: false,
10293 });
10294 config.features.provider_model_ref = true;
10295 config.defaults = Some(DefaultsConfig {
10296 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10297 fast: Some(bamboo_domain::ProviderModelRef::new(
10298 "anthropic",
10299 "claude-3-5-haiku",
10300 )),
10301 task_summary: None,
10302 vision: None,
10303 memory_background: None,
10304 planning: None,
10305 search: None,
10306 code_review: None,
10307 sub_agent: None,
10308 subagent_models: Default::default(),
10309 });
10310
10311 assert_eq!(
10312 config.get_fast_model(),
10313 Some("claude-3-5-haiku".to_string())
10314 );
10315 }
10316
10317 #[test]
10318 #[allow(clippy::field_reassign_with_default)]
10320 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10321 let mut config = Config::default();
10322 config.provider = "openai".to_string();
10323 config.providers.openai = Some(OpenAIConfig {
10324 api_key: "test".to_string(),
10325 api_key_encrypted: None,
10326 credential_ref: None,
10327 base_url: None,
10328 model: Some("gpt-4o".to_string()),
10329 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10330 vision_model: None,
10331 reasoning_effort: None,
10332 responses_only_models: vec![],
10333 request_overrides: None,
10334 extra: Default::default(),
10335 api_key_from_env: false,
10336 });
10337 config.features.provider_model_ref = false;
10338 config.defaults = Some(DefaultsConfig {
10339 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10340 fast: Some(bamboo_domain::ProviderModelRef::new(
10341 "anthropic",
10342 "claude-3-5-haiku",
10343 )),
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("legacy-gpt-4o-mini".to_string())
10357 );
10358 }
10359
10360 #[test]
10361 #[allow(clippy::field_reassign_with_default)]
10363 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10364 let mut config = Config::default();
10365 config.provider = "openai".to_string();
10366 config.features.provider_model_ref = true;
10367 config.defaults = Some(DefaultsConfig {
10368 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10369 fast: None,
10370 task_summary: None,
10371 vision: None,
10372 memory_background: None,
10373 planning: None,
10374 search: None,
10375 code_review: None,
10376 sub_agent: None,
10377 subagent_models: Default::default(),
10378 });
10379
10380 assert_eq!(
10381 config.get_fast_model(),
10382 Some("claude-3-7-sonnet".to_string())
10383 );
10384 }
10385
10386 #[test]
10387 #[allow(clippy::field_reassign_with_default)]
10389 fn get_memory_background_model_prefers_defaults_memory_background() {
10390 let mut config = Config::default();
10391 config.provider = "openai".to_string();
10392 config.providers.openai = Some(OpenAIConfig {
10393 api_key: "test".to_string(),
10394 api_key_encrypted: None,
10395 credential_ref: None,
10396 base_url: None,
10397 model: Some("gpt-4o".to_string()),
10398 fast_model: Some("gpt-4o-mini".to_string()),
10399 vision_model: None,
10400 reasoning_effort: None,
10401 responses_only_models: vec![],
10402 request_overrides: None,
10403 extra: Default::default(),
10404 api_key_from_env: false,
10405 });
10406 config.features.provider_model_ref = true;
10407 config.defaults = Some(DefaultsConfig {
10408 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10409 fast: Some(bamboo_domain::ProviderModelRef::new(
10410 "openai",
10411 "gpt-4o-mini",
10412 )),
10413 task_summary: None,
10414 vision: None,
10415 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10416 "anthropic",
10417 "claude-3-5-haiku",
10418 )),
10419 planning: None,
10420 search: None,
10421 code_review: None,
10422 sub_agent: None,
10423 subagent_models: Default::default(),
10424 });
10425
10426 assert_eq!(
10427 config.get_memory_background_model(),
10428 Some("claude-3-5-haiku".to_string())
10429 );
10430 }
10431
10432 #[test]
10433 #[allow(clippy::field_reassign_with_default)]
10435 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10436 let mut config = Config::default();
10437 config.provider = "openai".to_string();
10438 config.features.provider_model_ref = true;
10439 config.defaults = Some(DefaultsConfig {
10440 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10441 fast: Some(bamboo_domain::ProviderModelRef::new(
10442 "anthropic",
10443 "claude-3-5-haiku",
10444 )),
10445 task_summary: None,
10446 vision: None,
10447 memory_background: None,
10448 planning: None,
10449 search: None,
10450 code_review: None,
10451 sub_agent: None,
10452 subagent_models: Default::default(),
10453 });
10454
10455 assert_eq!(
10456 config.get_memory_background_model(),
10457 Some("claude-3-5-haiku".to_string())
10458 );
10459 }
10460
10461 #[test]
10462 #[allow(clippy::field_reassign_with_default)]
10464 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10465 let mut config = Config::default();
10466 config.provider = "openai".to_string();
10467 config.providers.openai = Some(OpenAIConfig {
10468 api_key: "test".to_string(),
10469 api_key_encrypted: None,
10470 credential_ref: None,
10471 base_url: None,
10472 model: Some("gpt-4o".to_string()),
10473 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10474 vision_model: None,
10475 reasoning_effort: None,
10476 responses_only_models: vec![],
10477 request_overrides: None,
10478 extra: Default::default(),
10479 api_key_from_env: false,
10480 });
10481 config.features.provider_model_ref = false;
10482 config.defaults = Some(DefaultsConfig {
10483 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10484 fast: Some(bamboo_domain::ProviderModelRef::new(
10485 "anthropic",
10486 "claude-3-5-haiku",
10487 )),
10488 task_summary: None,
10489 vision: None,
10490 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10491 "anthropic",
10492 "claude-3-5-haiku",
10493 )),
10494 planning: None,
10495 search: None,
10496 code_review: None,
10497 sub_agent: None,
10498 subagent_models: Default::default(),
10499 });
10500
10501 assert_eq!(
10502 config.get_memory_background_model(),
10503 Some("legacy-gpt-4o-mini".to_string())
10504 );
10505 }
10506
10507 #[test]
10514 fn is_host_trusted_requires_https_scheme() {
10515 let hosts = vec!["github.com/bigduu/".to_string()];
10516 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10517 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10518 }
10519
10520 #[test]
10521 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10522 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10524 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10525 let hosts = vec!["github.com/bigduu/".to_string()];
10527 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10528 }
10529
10530 #[test]
10531 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10532 let hosts = vec!["trusted.example.com".to_string()];
10533 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10534 assert!(!is_host_trusted(
10538 "https://trusted.example.com.evil.com/x",
10539 &hosts
10540 ));
10541 assert!(!is_host_trusted(
10542 "https://trusted.example.comevil.com/x",
10543 &hosts
10544 ));
10545 }
10546
10547 #[test]
10548 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10549 let hosts = vec!["github.com/bigduu/".to_string()];
10551 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10552 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10553 }
10554
10555 #[test]
10556 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10557 let hosts = vec!["example.com".to_string()];
10558 assert!(is_host_trusted("https://example.com/", &hosts));
10559 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10560 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10563 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10564 }
10565
10566 #[test]
10567 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10568 let hosts = vec!["github.com/bigduu/".to_string()];
10569 assert!(is_host_trusted(
10571 "https://someuser@github.com/bigduu/x",
10572 &hosts
10573 ));
10574 assert!(!is_host_trusted(
10577 "https://github.com@evil.com/bigduu/",
10578 &hosts
10579 ));
10580 }
10581
10582 #[test]
10583 fn is_host_trusted_ignores_an_explicit_port() {
10584 let hosts = vec!["github.com/bigduu/".to_string()];
10585 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10586 }
10587
10588 #[test]
10589 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10590 let hosts = vec!["github.com/bigduu/".to_string()];
10591 assert!(!is_host_trusted("not a url at all", &hosts));
10592 assert!(!is_host_trusted("", &hosts));
10593 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10595
10596 #[test]
10597 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10598 let hosts = vec!["github.com/bigduu/".to_string()];
10599 assert!(!is_host_trusted(
10602 "https://github.com/bigduu/../evil/x",
10603 &hosts
10604 ));
10605 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10607 }
10608
10609 #[test]
10610 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10611 let mut config = Config::default();
10612 config.plugin_trust.trusted_hosts = vec![
10613 " GitHub.com/BigDuu/ ".to_string(),
10614 "".to_string(),
10615 " ".to_string(),
10616 "Example.COM".to_string(),
10617 ];
10618 config.normalize_plugin_trust_settings();
10619 assert_eq!(
10620 config.plugin_trust.trusted_hosts,
10621 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10622 );
10623 }
10624
10625 #[test]
10631 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10632 let config = Config::default();
10634 assert_eq!(
10635 config.plugin_trust.enforcement,
10636 PluginTrustEnforcement::Strict
10637 );
10638 assert!(!config.plugin_trust.enforcement_is_off());
10639
10640 let json = serde_json::json!({
10646 "trusted_hosts": ["example.com"],
10647 "trusted_keys": [],
10648 });
10649 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10650 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10651 }
10652
10653 #[test]
10654 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10655 for raw in ["off", "OFF", "Off", " off "] {
10656 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10657 "enforcement": raw,
10658 }))
10659 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10660 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10661 assert!(trust.enforcement_is_off());
10662 }
10663 for raw in ["strict", "STRICT", " Strict "] {
10664 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10665 "enforcement": raw,
10666 }))
10667 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10668 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10669 }
10670
10671 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10672 "enforcement": "nonsense",
10673 }))
10674 .expect_err("an unrecognized string must be rejected, not silently default");
10675 assert!(err.to_string().contains("nonsense"));
10676 }
10677
10678 #[test]
10679 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10680 let trust: PluginTrustConfig =
10683 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10684 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10685
10686 let trust: PluginTrustConfig =
10687 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10688 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10689 }
10690
10691 #[test]
10692 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10693 let trust = PluginTrustConfig {
10698 enforcement: PluginTrustEnforcement::Off,
10699 ..PluginTrustConfig::default()
10700 };
10701 let json = serde_json::to_value(&trust).unwrap();
10702 assert_eq!(json["enforcement"], "off");
10703
10704 let trust = PluginTrustConfig {
10705 enforcement: PluginTrustEnforcement::Strict,
10706 ..PluginTrustConfig::default()
10707 };
10708 let json = serde_json::to_value(&trust).unwrap();
10709 assert_eq!(json["enforcement"], "strict");
10710 }
10711
10712 #[test]
10713 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10714 let mut config = Config::default();
10717 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10718 config.normalize_plugin_trust_settings();
10719 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10720 }
10721
10722 #[test]
10723 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10724 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10729 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10730 )));
10731 assert_eq!(
10732 config.plugin_trust.enforcement,
10733 PluginTrustEnforcement::Strict
10734 );
10735
10736 let outcome = crate::dot_path::apply_dot_path_set(
10737 &config,
10738 "plugin_trust.enforcement",
10739 crate::dot_path::parse_cli_value("off"),
10740 )
10741 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10742 assert_eq!(
10743 outcome.config.plugin_trust.enforcement,
10744 PluginTrustEnforcement::Off
10745 );
10746
10747 let outcome = crate::dot_path::apply_dot_path_set(
10749 &outcome.config,
10750 "plugin_trust.enforcement",
10751 crate::dot_path::parse_cli_value("strict"),
10752 )
10753 .expect("setting it back to strict should also round-trip");
10754 assert_eq!(
10755 outcome.config.plugin_trust.enforcement,
10756 PluginTrustEnforcement::Strict
10757 );
10758 }
10759}
10760
10761#[cfg(test)]
10762mod final_evidence_feature_tests {
10763 use super::FeatureFlags;
10764
10765 #[test]
10766 fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
10767 let existing: FeatureFlags =
10768 serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
10769 assert!(!existing.final_evidence_check);
10770 let enabled: FeatureFlags =
10771 serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
10772 let restored: FeatureFlags =
10773 serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
10774 assert!(restored.final_evidence_check);
10775 assert!(!FeatureFlags::default().final_evidence_check);
10776 }
10777}