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; 9] = [
2556 "SessionStart",
2557 "UserPromptSubmit",
2558 "PreToolUse",
2559 "PostToolUse",
2560 "Stop",
2561 "SessionEnd",
2562 "PreCompact",
2563 "Notification",
2564 "BambooModelCallUsage",
2565];
2566
2567fn default_lifecycle_hook_timeout_ms() -> u64 {
2568 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2569}
2570
2571fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2572 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2573}
2574
2575fn lifecycle_hook_enabled_default() -> bool {
2576 true
2577}
2578
2579fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2580 *value
2581}
2582
2583#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2588pub struct LifecycleHooksConfig {
2589 #[serde(default)]
2590 pub enabled: bool,
2591 #[serde(
2592 default,
2593 rename = "SessionStart",
2594 skip_serializing_if = "Vec::is_empty"
2595 )]
2596 pub session_start: Vec<LifecycleHookGroup>,
2597 #[serde(
2598 default,
2599 rename = "UserPromptSubmit",
2600 skip_serializing_if = "Vec::is_empty"
2601 )]
2602 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2603 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2604 pub pre_tool_use: Vec<LifecycleHookGroup>,
2605 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2606 pub post_tool_use: Vec<LifecycleHookGroup>,
2607 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2608 pub stop: Vec<LifecycleHookGroup>,
2609 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2610 pub session_end: Vec<LifecycleHookGroup>,
2611 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2612 pub pre_compact: Vec<LifecycleHookGroup>,
2613 #[serde(
2614 default,
2615 rename = "Notification",
2616 skip_serializing_if = "Vec::is_empty"
2617 )]
2618 pub notification: Vec<LifecycleHookGroup>,
2619 #[serde(
2620 default,
2621 rename = "BambooModelCallUsage",
2622 skip_serializing_if = "Vec::is_empty"
2623 )]
2624 pub model_call_usage: Vec<LifecycleHookGroup>,
2625}
2626
2627impl LifecycleHooksConfig {
2628 pub fn is_empty(&self) -> bool {
2629 !self.enabled
2630 && self.session_start.is_empty()
2631 && self.user_prompt_submit.is_empty()
2632 && self.pre_tool_use.is_empty()
2633 && self.post_tool_use.is_empty()
2634 && self.stop.is_empty()
2635 && self.session_end.is_empty()
2636 && self.pre_compact.is_empty()
2637 && self.notification.is_empty()
2638 && self.model_call_usage.is_empty()
2639 }
2640}
2641
2642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2644pub struct LifecycleHookGroup {
2645 #[serde(
2648 default = "lifecycle_hook_enabled_default",
2649 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2650 )]
2651 pub enabled: bool,
2652 #[serde(default, skip_serializing_if = "Option::is_none")]
2653 pub matcher: Option<String>,
2654 #[serde(default)]
2655 pub hooks: Vec<LifecycleHookHandler>,
2656}
2657
2658impl Default for LifecycleHookGroup {
2659 fn default() -> Self {
2660 Self {
2661 enabled: true,
2662 matcher: None,
2663 hooks: Vec::new(),
2664 }
2665 }
2666}
2667
2668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2673#[serde(tag = "type", rename_all = "lowercase")]
2674pub enum LifecycleHookHandler {
2675 Command {
2676 command: String,
2677 #[serde(
2678 default = "default_lifecycle_hook_timeout_ms",
2679 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2680 )]
2681 timeout_ms: u64,
2682 },
2683 Script {
2684 path: String,
2685 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2686 runner: LifecycleScriptRunner,
2687 #[serde(
2688 default = "default_lifecycle_hook_timeout_ms",
2689 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2690 )]
2691 timeout_ms: u64,
2692 },
2693}
2694
2695#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2701#[serde(rename_all = "lowercase")]
2702pub enum LifecycleScriptRunner {
2703 #[default]
2704 Auto,
2705 Node,
2706 Bun,
2707 Python,
2708 Bash,
2709 PowerShell,
2710 Cmd,
2711}
2712
2713impl LifecycleScriptRunner {
2714 pub fn is_auto(&self) -> bool {
2715 matches!(self, Self::Auto)
2716 }
2717
2718 pub fn as_str(self) -> &'static str {
2719 match self {
2720 Self::Auto => "auto",
2721 Self::Node => "node",
2722 Self::Bun => "bun",
2723 Self::Python => "python",
2724 Self::Bash => "bash",
2725 Self::PowerShell => "powershell",
2726 Self::Cmd => "cmd",
2727 }
2728 }
2729
2730 pub fn supports_path(self, path: &str) -> bool {
2732 let extension = lifecycle_script_extension(path);
2733 match self {
2734 Self::Auto => extension.is_some(),
2735 Self::Node | Self::Bun => {
2736 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2737 }
2738 Self::Python => matches!(extension.as_deref(), Some("py")),
2739 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2740 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2741 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2742 }
2743 }
2744}
2745
2746pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2749 let extension = std::path::Path::new(path)
2750 .extension()?
2751 .to_str()?
2752 .to_ascii_lowercase();
2753 matches!(
2754 extension.as_str(),
2755 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2756 )
2757 .then_some(extension)
2758}
2759
2760impl LifecycleHookHandler {
2761 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2762 Self::Command {
2763 command: command.into(),
2764 timeout_ms,
2765 }
2766 }
2767
2768 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2769 Self::Script {
2770 path: path.into(),
2771 runner,
2772 timeout_ms,
2773 }
2774 }
2775
2776 pub fn timeout_ms(&self) -> u64 {
2777 match self {
2778 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2779 }
2780 }
2781}
2782
2783#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2790pub struct RequestOverridesConfig {
2791 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2793 pub common: RequestScopeOverride,
2794 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2796 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2797 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2799 pub rules: Vec<ModelRequestRule>,
2800}
2801
2802#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2804pub struct ModelRequestRule {
2805 pub model_pattern: String,
2807 #[serde(default, skip_serializing_if = "Option::is_none")]
2809 pub endpoint: Option<String>,
2810 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2812 pub scope: RequestScopeOverride,
2813}
2814
2815#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2817pub struct RequestScopeOverride {
2818 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2820 pub headers: BTreeMap<String, TemplateExpr>,
2821 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2823 pub body_patch: Vec<BodyPatch>,
2824}
2825
2826impl RequestScopeOverride {
2827 pub fn is_empty(&self) -> bool {
2828 self.headers.is_empty() && self.body_patch.is_empty()
2829 }
2830}
2831
2832#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2834pub struct BodyPatch {
2835 pub path: String,
2837 #[serde(default)]
2839 pub op: BodyPatchOp,
2840 #[serde(default, skip_serializing_if = "Option::is_none")]
2842 pub value: Option<PatchValue>,
2843}
2844
2845#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2847#[serde(rename_all = "snake_case")]
2848pub enum BodyPatchOp {
2849 #[default]
2850 Set,
2851 Remove,
2852}
2853
2854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2856#[serde(untagged)]
2857pub enum PatchValue {
2858 Template(TemplateExpr),
2859 Json(Value),
2860}
2861
2862#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2864#[serde(untagged)]
2865pub enum TemplateExpr {
2866 Literal(String),
2868 Structured(TemplateExprSpec),
2870}
2871
2872#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2874#[serde(tag = "type", rename_all = "snake_case")]
2875pub enum TemplateExprSpec {
2876 Literal { value: String },
2878 EnvRef {
2880 name: String,
2881 #[serde(default, skip_serializing_if = "Option::is_none")]
2882 fallback: Option<String>,
2883 },
2884 Generated { generator: GeneratedValue },
2886 Format { template: String },
2888}
2889
2890#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2892#[serde(rename_all = "snake_case")]
2893pub enum GeneratedValue {
2894 Uuid,
2895 UnixMs,
2896}
2897
2898#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2900pub struct ToolsConfig {
2901 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2903 pub disabled: Vec<String>,
2904
2905 #[serde(default, flatten)]
2907 pub extra: BTreeMap<String, Value>,
2908}
2909
2910impl ToolsConfig {
2911 fn is_empty(&self) -> bool {
2912 self.disabled.is_empty() && self.extra.is_empty()
2913 }
2914}
2915
2916#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2918pub struct SkillsConfig {
2919 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2921 pub disabled: Vec<String>,
2922
2923 #[serde(default, flatten)]
2925 pub extra: BTreeMap<String, Value>,
2926}
2927
2928impl SkillsConfig {
2929 fn is_empty(&self) -> bool {
2930 self.disabled.is_empty() && self.extra.is_empty()
2931 }
2932}
2933
2934#[derive(Debug, Clone, Serialize, Deserialize)]
2939pub struct ImageFallbackHookConfig {
2940 #[serde(default = "default_true_hooks")]
2941 pub enabled: bool,
2942
2943 #[serde(default = "default_image_fallback_mode")]
2945 pub mode: String,
2946}
2947
2948impl Default for ImageFallbackHookConfig {
2949 fn default() -> Self {
2950 Self {
2951 enabled: default_true_hooks(),
2952 mode: default_image_fallback_mode(),
2953 }
2954 }
2955}
2956
2957fn default_image_fallback_mode() -> String {
2958 "placeholder".to_string()
2959}
2960
2961fn default_true_hooks() -> bool {
2962 false
2965}
2966
2967pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2979
2980#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2981pub struct OpenAIConfig {
2982 #[serde(default, skip_serializing)]
2986 pub api_key: String,
2987 #[serde(default, skip_serializing_if = "Option::is_none")]
2989 pub api_key_encrypted: Option<String>,
2990 #[serde(default, skip_serializing_if = "Option::is_none")]
2992 pub credential_ref: Option<crate::CredentialRef>,
2993 #[serde(skip)]
2998 pub api_key_from_env: bool,
2999 #[serde(skip_serializing_if = "Option::is_none")]
3001 pub base_url: Option<String>,
3002 #[serde(skip_serializing_if = "Option::is_none")]
3004 pub model: Option<String>,
3005 #[serde(default, skip_serializing_if = "Option::is_none")]
3008 pub fast_model: Option<String>,
3009 #[serde(default, skip_serializing_if = "Option::is_none")]
3012 pub vision_model: Option<String>,
3013 #[serde(skip_serializing_if = "Option::is_none")]
3015 pub reasoning_effort: Option<ReasoningEffort>,
3016
3017 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3024 pub responses_only_models: Vec<String>,
3025 #[serde(default, skip_serializing_if = "Option::is_none")]
3027 pub request_overrides: Option<RequestOverridesConfig>,
3028
3029 #[serde(default, flatten)]
3031 pub extra: BTreeMap<String, Value>,
3032}
3033
3034impl OpenAIConfig {
3035 pub fn explicit_prompt_cache_enabled(&self) -> bool {
3042 self.extra
3043 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3044 .and_then(Value::as_bool)
3045 .unwrap_or(true)
3046 }
3047}
3048
3049#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3061pub struct AnthropicConfig {
3062 #[serde(default, skip_serializing)]
3066 pub api_key: String,
3067 #[serde(default, skip_serializing_if = "Option::is_none")]
3069 pub api_key_encrypted: Option<String>,
3070 #[serde(default, skip_serializing_if = "Option::is_none")]
3072 pub credential_ref: Option<crate::CredentialRef>,
3073 #[serde(skip)]
3078 pub api_key_from_env: bool,
3079 #[serde(skip_serializing_if = "Option::is_none")]
3081 pub base_url: Option<String>,
3082 #[serde(skip_serializing_if = "Option::is_none")]
3084 pub model: Option<String>,
3085 #[serde(default, skip_serializing_if = "Option::is_none")]
3088 pub fast_model: Option<String>,
3089 #[serde(default, skip_serializing_if = "Option::is_none")]
3092 pub vision_model: Option<String>,
3093 #[serde(skip_serializing_if = "Option::is_none")]
3095 pub max_tokens: Option<u32>,
3096 #[serde(skip_serializing_if = "Option::is_none")]
3098 pub reasoning_effort: Option<ReasoningEffort>,
3099 #[serde(default, skip_serializing_if = "Option::is_none")]
3101 pub request_overrides: Option<RequestOverridesConfig>,
3102
3103 #[serde(default, skip_serializing_if = "Option::is_none")]
3120 pub thinking_replay_always: Option<bool>,
3121
3122 #[serde(default, flatten)]
3124 pub extra: BTreeMap<String, Value>,
3125}
3126
3127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3138pub struct GeminiConfig {
3139 #[serde(default, skip_serializing)]
3143 pub api_key: String,
3144 #[serde(default, skip_serializing_if = "Option::is_none")]
3146 pub api_key_encrypted: Option<String>,
3147 #[serde(default, skip_serializing_if = "Option::is_none")]
3149 pub credential_ref: Option<crate::CredentialRef>,
3150 #[serde(skip)]
3155 pub api_key_from_env: bool,
3156 #[serde(skip_serializing_if = "Option::is_none")]
3158 pub base_url: Option<String>,
3159 #[serde(skip_serializing_if = "Option::is_none")]
3161 pub model: Option<String>,
3162 #[serde(default, skip_serializing_if = "Option::is_none")]
3165 pub fast_model: Option<String>,
3166 #[serde(default, skip_serializing_if = "Option::is_none")]
3169 pub vision_model: Option<String>,
3170 #[serde(skip_serializing_if = "Option::is_none")]
3172 pub reasoning_effort: Option<ReasoningEffort>,
3173 #[serde(default, skip_serializing_if = "Option::is_none")]
3175 pub request_overrides: Option<RequestOverridesConfig>,
3176
3177 #[serde(default, flatten)]
3179 pub extra: BTreeMap<String, Value>,
3180}
3181
3182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3194pub struct CopilotConfig {
3195 #[serde(default)]
3197 pub enabled: bool,
3198 #[serde(default)]
3200 pub headless_auth: bool,
3201 #[serde(skip_serializing_if = "Option::is_none")]
3203 pub model: Option<String>,
3204 #[serde(default, skip_serializing_if = "Option::is_none")]
3207 pub fast_model: Option<String>,
3208 #[serde(default, skip_serializing_if = "Option::is_none")]
3211 pub vision_model: Option<String>,
3212 #[serde(skip_serializing_if = "Option::is_none")]
3214 pub reasoning_effort: Option<ReasoningEffort>,
3215
3216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3225 pub responses_only_models: Vec<String>,
3226 #[serde(default, skip_serializing_if = "Option::is_none")]
3228 pub request_overrides: Option<RequestOverridesConfig>,
3229
3230 #[serde(default, flatten)]
3232 pub extra: BTreeMap<String, Value>,
3233}
3234
3235#[derive(Debug, Clone, Serialize, Deserialize)]
3240pub struct BodhiConfig {
3241 #[serde(default, skip_serializing)]
3243 pub api_key: String,
3244 #[serde(default, skip_serializing_if = "Option::is_none")]
3246 pub api_key_encrypted: Option<String>,
3247 #[serde(default, skip_serializing_if = "Option::is_none")]
3249 pub credential_ref: Option<crate::CredentialRef>,
3250 #[serde(skip_serializing_if = "Option::is_none")]
3252 pub base_url: Option<String>,
3253 #[serde(skip_serializing_if = "Option::is_none")]
3255 pub target_provider: Option<String>,
3256 #[serde(skip_serializing_if = "Option::is_none")]
3258 pub reasoning_effort: Option<ReasoningEffort>,
3259
3260 #[serde(default, flatten)]
3262 pub extra: BTreeMap<String, Value>,
3263}
3264
3265fn default_provider() -> String {
3267 "anthropic".to_string()
3268}
3269
3270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3302pub struct ProviderInstanceConfig {
3303 pub provider_type: String,
3308
3309 #[serde(default, skip_serializing_if = "Option::is_none")]
3311 pub label: Option<String>,
3312
3313 #[serde(default, skip_serializing)]
3315 pub api_key: String,
3316
3317 #[serde(default, skip_serializing_if = "Option::is_none")]
3320 pub api_key_encrypted: Option<String>,
3321
3322 #[serde(default, skip_serializing_if = "Option::is_none")]
3324 pub credential_ref: Option<crate::CredentialRef>,
3325
3326 #[serde(default, skip_serializing_if = "Option::is_none")]
3328 pub base_url: Option<String>,
3329
3330 #[serde(default, skip_serializing_if = "Option::is_none")]
3332 pub model: Option<String>,
3333
3334 #[serde(default, skip_serializing_if = "Option::is_none")]
3336 pub fast_model: Option<String>,
3337
3338 #[serde(default, skip_serializing_if = "Option::is_none")]
3340 pub vision_model: Option<String>,
3341
3342 #[serde(default, skip_serializing_if = "Option::is_none")]
3344 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3345
3346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3348 pub responses_only_models: Vec<String>,
3349
3350 #[serde(default, skip_serializing_if = "Option::is_none")]
3352 pub request_overrides: Option<RequestOverridesConfig>,
3353
3354 #[serde(default = "default_true")]
3357 pub enabled: bool,
3358
3359 #[serde(default, flatten)]
3361 pub extra: BTreeMap<String, Value>,
3362}
3363
3364fn default_true() -> bool {
3365 true
3366}
3367
3368fn default_port() -> u16 {
3370 9562
3371}
3372
3373fn default_bind() -> String {
3375 "127.0.0.1".to_string()
3376}
3377
3378fn default_workers() -> usize {
3380 10
3381}
3382
3383fn default_data_dir() -> PathBuf {
3385 super::paths::bamboo_dir()
3386}
3387
3388#[derive(Debug, Clone, Serialize, Deserialize)]
3390pub struct ServerConfig {
3391 #[serde(default = "default_port")]
3393 pub port: u16,
3394
3395 #[serde(default = "default_bind")]
3397 pub bind: String,
3398
3399 pub static_dir: Option<PathBuf>,
3401
3402 #[serde(default = "default_workers")]
3404 pub workers: usize,
3405
3406 #[serde(default, skip_serializing_if = "Option::is_none")]
3414 pub tls: Option<TlsConfig>,
3415
3416 #[serde(default, flatten)]
3418 pub extra: BTreeMap<String, Value>,
3419}
3420
3421impl Default for ServerConfig {
3422 fn default() -> Self {
3423 Self {
3424 port: default_port(),
3425 bind: default_bind(),
3426 static_dir: None,
3427 workers: default_workers(),
3428 tls: None,
3429 extra: BTreeMap::new(),
3430 }
3431 }
3432}
3433
3434#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3440pub struct TlsConfig {
3441 pub cert_file: PathBuf,
3443 pub key_file: PathBuf,
3445}
3446
3447#[derive(Debug, Clone, Serialize, Deserialize)]
3449pub struct ProxyAuth {
3450 pub username: String,
3452 pub password: String,
3454}
3455
3456fn parse_bool_env(value: &str) -> bool {
3460 matches!(
3461 value.trim().to_ascii_lowercase().as_str(),
3462 "1" | "true" | "yes" | "y" | "on"
3463 )
3464}
3465
3466fn expand_user_path(value: &str) -> PathBuf {
3467 let trimmed = value.trim();
3468 if let Some(rest) = trimmed.strip_prefix("~/") {
3469 if let Some(home) = dirs::home_dir() {
3470 return home.join(rest);
3471 }
3472 }
3473 PathBuf::from(trimmed)
3474}
3475
3476impl Default for Config {
3477 fn default() -> Self {
3478 Self::create_default()
3487 }
3488}
3489
3490#[derive(Debug, Clone, PartialEq, Eq)]
3492pub struct PromptSafeEnvVarEntry {
3493 pub name: String,
3494 pub secret: bool,
3495 pub description: Option<String>,
3496}
3497
3498static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3502
3503static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3504
3505fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3506 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3507}
3508
3509fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3510 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3511}
3512
3513impl Config {
3514 fn from_parts(
3515 values: ConfigValues,
3516 memory: Option<MemoryConfig>,
3517 subagents: SubagentsConfig,
3518 providers: ProviderConfigs,
3519 ) -> Self {
3520 Self {
3521 values,
3522 memory: crate::MemoryConfigModule(memory),
3523 subagents: crate::SubagentsConfigModule(subagents),
3524 providers: crate::ProviderConfigsModule(providers),
3525 recovery_status: None,
3526 }
3527 }
3528
3529 pub(crate) fn section_values(&self) -> ConfigValues {
3535 self.values.clone()
3536 }
3537
3538 pub(crate) fn from_section_parts(
3539 values: ConfigValues,
3540 memory: Option<MemoryConfig>,
3541 subagents: SubagentsConfig,
3542 providers: ProviderConfigs,
3543 ) -> Self {
3544 Self::from_parts(values, memory, subagents, providers)
3545 }
3546
3547 pub fn memory(&self) -> &Option<MemoryConfig> {
3549 &self.memory.0
3550 }
3551
3552 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3553 &mut self.memory.0
3554 }
3555
3556 pub fn subagents(&self) -> &SubagentsConfig {
3558 &self.subagents.0
3559 }
3560
3561 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3562 &mut self.subagents.0
3563 }
3564
3565 pub fn providers(&self) -> &ProviderConfigs {
3567 &self.providers.0
3568 }
3569
3570 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3571 &mut self.providers.0
3572 }
3573
3574 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3578 if self
3579 .default_provider_instance
3580 .as_ref()
3581 .is_some_and(|id| self.provider_instances.contains_key(id))
3582 {
3583 self.providers.0.clear_legacy_builtin_aliases();
3584 }
3585 }
3586
3587 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3593 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3594 let object = value
3595 .as_object_mut()
3596 .expect("ConfigRoot always serializes as a JSON object");
3597 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3598 object.insert(
3599 "subagents".to_string(),
3600 serde_json::to_value(self.subagents())?,
3601 );
3602 object.insert(
3603 "providers".to_string(),
3604 serde_json::to_value(self.providers())?,
3605 );
3606 Ok(value)
3607 }
3608
3609 pub fn new() -> Self {
3628 Self::from_data_dir(None)
3629 }
3630
3631 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3632 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3633 Ok(facade) => facade.effective_config(),
3634 Err(error) => {
3635 tracing::warn!(
3636 error = %error,
3637 "completed modular configuration is unavailable; refusing legacy-root fallback"
3638 );
3639 Self::create_default()
3640 }
3641 };
3642
3643 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3644 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3645 config.proxy_auth = None;
3646 }
3647 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3648 tracing::warn!(error = %error, "provider credential hydration unavailable");
3649 }
3650 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3651 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3652 }
3653 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3654 tracing::warn!(error = %error, "env credential hydration unavailable");
3655 for entry in &mut config.env_vars {
3656 if entry.secret {
3657 entry.value.clear();
3658 }
3659 }
3660 }
3661 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3662 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3663 config.clear_cluster_runtime_credentials();
3664 }
3665 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3666 tracing::warn!(error = %error, "notification credential hydration unavailable");
3667 config.notifications.ntfy.token = None;
3668 config.notifications.bark.device_key = None;
3669 }
3670 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3671 tracing::warn!(error = %error, "connect credential hydration unavailable");
3672 for platform in &mut config.connect.platforms {
3673 platform.token = None;
3674 platform.app_secret = None;
3675 }
3676 }
3677 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3678 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3679 config.clear_access_control_runtime_verifiers();
3680 }
3681 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3682 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3683 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3684 broker.token.clear();
3685 }
3686 }
3687 config.normalize_tool_settings();
3688 config.normalize_skill_settings();
3689 config.normalize_plugin_trust_settings();
3690 config.extra.remove("data_dir");
3691 if apply_env {
3692 config.apply_env_overrides();
3693 }
3694 if publish {
3695 config.publish_env_vars();
3696 }
3697 config
3698 }
3699
3700 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3709 let data_dir = data_dir
3711 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3712 .unwrap_or_else(default_data_dir);
3713
3714 match crate::modular_authority_boundary_present(&data_dir) {
3715 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3716 Ok(false) => {}
3717 Err(error) => {
3718 tracing::warn!(
3719 error = %error,
3720 "modular configuration marker is unavailable; refusing legacy-root fallback"
3721 );
3722 let mut config = Self::create_default();
3723 if apply_env {
3724 config.apply_env_overrides();
3725 }
3726 if publish {
3727 config.publish_env_vars();
3728 }
3729 return config;
3730 }
3731 }
3732
3733 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3738 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3739 .map_err(|error| {
3740 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3741 error
3742 })
3743 .is_ok();
3744 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3745 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3746 .map_err(|error| {
3747 tracing::warn!(error = %error, "cluster credential migration unavailable");
3748 error
3749 })
3750 .is_ok();
3751
3752 match crate::modular_authority_boundary_present(&data_dir) {
3757 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3758 Ok(false) => {}
3759 Err(error) => {
3760 tracing::warn!(
3761 error = %error,
3762 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3763 );
3764 let mut config = Self::create_default();
3765 if apply_env {
3766 config.apply_env_overrides();
3767 }
3768 if publish {
3769 config.publish_env_vars();
3770 }
3771 return config;
3772 }
3773 }
3774
3775 let config_path = data_dir.join("config.json");
3776
3777 let mut config = if config_path.exists() {
3778 if let Ok(content) = std::fs::read_to_string(&config_path) {
3779 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3780 tracing::warn!(
3790 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3791 e
3792 );
3793 let quarantine_path = quarantine_corrupt_config(&config_path);
3794 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3795 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3796 .or_else(|| {
3797 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3798 (cfg, ConfigRecoverySource::Backup { generation })
3799 })
3800 })
3801 .unwrap_or_else(|| {
3802 tracing::warn!(
3803 "Could not salvage and no usable config.json.bak; using defaults"
3804 );
3805 (Self::create_default(), ConfigRecoverySource::Defaults)
3806 });
3807 recovered.recovery_status = Some(ConfigRecoveryStatus {
3808 source,
3809 quarantine_path,
3810 confirmed: false,
3811 });
3812 recovered
3813 })
3814 } else {
3815 Self::create_default()
3816 }
3817 } else {
3818 Self::create_default()
3819 };
3820
3821 let mut memory_module = config.memory.clone();
3827 match memory_module.load_sync(&data_dir) {
3828 Ok(true) => config.memory = memory_module,
3829 Ok(false) => {}
3830 Err(error) => tracing::warn!(
3831 "Failed to load memory.json; using legacy config.json memory: {error}"
3832 ),
3833 }
3834 let mut subagents_module = config.subagents.clone();
3835 match subagents_module.load_sync(&data_dir) {
3836 Ok(true) => config.subagents = subagents_module,
3837 Ok(false) => {}
3838 Err(error) => tracing::warn!(
3839 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3840 ),
3841 }
3842 if provider_mcp_ready {
3843 let mut providers_module = config.providers.clone();
3844 match providers_module.load_sync(&data_dir) {
3845 Ok(true) => config.providers = providers_module,
3846 Ok(false) => {}
3847 Err(error) => tracing::warn!(
3848 "Failed to load providers.json; using legacy config.json providers: {error}"
3849 ),
3850 }
3851 }
3852
3853 config.hydrate_proxy_auth_from_encrypted();
3855 if provider_mcp_ready {
3856 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3857 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3858 config.proxy_auth = None;
3859 }
3860 }
3861 config.hydrate_provider_api_keys_from_encrypted();
3863 config.hydrate_provider_instance_api_keys_from_encrypted();
3865 config.hydrate_mcp_secrets_from_encrypted();
3867 if provider_mcp_ready {
3868 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3869 tracing::warn!(error = %error, "provider credential hydration unavailable");
3870 }
3871 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3872 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3873 }
3874 }
3875 config.hydrate_env_vars_from_encrypted();
3877 if provider_mcp_ready {
3878 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3879 tracing::warn!(error = %error, "env credential hydration unavailable");
3880 for entry in &mut config.env_vars {
3881 if entry.secret {
3882 entry.value.clear();
3883 }
3884 }
3885 }
3886 }
3887 if cluster_ready {
3892 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3893 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3894 config.clear_cluster_runtime_credentials();
3895 }
3896 } else {
3897 config.clear_cluster_runtime_credentials();
3898 }
3899 config.hydrate_broker_token_from_encrypted();
3901 config.hydrate_notifications_from_encrypted();
3903 if provider_mcp_ready {
3904 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3905 tracing::warn!(error = %error, "notification credential hydration unavailable");
3906 config.notifications.ntfy.token = None;
3907 config.notifications.bark.device_key = None;
3908 }
3909 } else {
3910 config.notifications.ntfy.token = None;
3915 config.notifications.bark.device_key = None;
3916 }
3917 config.merge_connect_config(&data_dir);
3922 scrub_legacy_connect_from_config_backups(&data_dir);
3932 config.hydrate_connect_platform_tokens_from_encrypted();
3934 if provider_mcp_ready {
3935 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3936 tracing::warn!(error = %error, "connect credential hydration unavailable");
3937 for platform in &mut config.connect.platforms {
3938 platform.token = None;
3939 platform.app_secret = None;
3940 }
3941 }
3942 }
3943 if provider_mcp_ready {
3944 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3945 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3946 config.clear_access_control_runtime_verifiers();
3947 }
3948 } else {
3949 config.clear_access_control_runtime_verifiers();
3950 }
3951 config.normalize_tool_settings();
3952 config.normalize_skill_settings();
3953 config.normalize_plugin_trust_settings();
3954
3955 config.extra.remove("data_dir");
3958
3959 if apply_env {
3963 config.apply_env_overrides();
3964 }
3965
3966 if publish {
3970 config.publish_env_vars();
3971 }
3972
3973 config
3974 }
3975
3976 fn apply_env_overrides(&mut self) {
3980 if let Ok(port) = std::env::var("BAMBOO_PORT") {
3981 if let Ok(port) = port.parse() {
3982 self.server.port = port;
3983 }
3984 }
3985
3986 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3987 self.server.bind = bind;
3988 }
3989
3990 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3995 let provider = provider.trim().to_string();
3996 if !provider.is_empty() {
3997 self.provider = provider.clone();
3998 if !self.provider_instances.is_empty() {
3999 let selected = self
4000 .provider_instances
4001 .contains_key(&provider)
4002 .then_some(provider.clone())
4003 .or_else(|| {
4004 let mut matching = self
4005 .provider_instances
4006 .iter()
4007 .filter(|(_, instance)| {
4008 instance.enabled && instance.provider_type == provider
4009 })
4010 .map(|(id, _)| id.clone())
4011 .collect::<Vec<_>>();
4012 matching.sort();
4013 matching.into_iter().next()
4014 });
4015 self.default_provider_instance = selected;
4016 }
4017 }
4018 }
4019
4020 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4021 self.headless_auth = parse_bool_env(&headless);
4022 }
4023
4024 if let Ok(project_prompt_injection) =
4025 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4026 {
4027 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4028 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4029 }
4030
4031 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4032 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4033 memory.relevant_recall = parse_bool_env(&relevant_recall);
4034 }
4035
4036 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4037 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4038 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4039 }
4040
4041 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4042 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4043 memory.project_first_dream = parse_bool_env(&project_first_dream);
4044 }
4045
4046 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4053 let key = key.trim();
4054 if !key.is_empty() {
4055 if self.provider_instances.is_empty() {
4056 let openai = self
4057 .providers
4058 .openai
4059 .get_or_insert_with(OpenAIConfig::default);
4060 openai.api_key = key.to_string();
4061 openai.api_key_from_env = true;
4062 } else {
4063 self.apply_provider_instance_env_key("openai", key);
4064 }
4065 }
4066 }
4067 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4068 let key = key.trim();
4069 if !key.is_empty() {
4070 if self.provider_instances.is_empty() {
4071 let anthropic = self
4072 .providers
4073 .anthropic
4074 .get_or_insert_with(AnthropicConfig::default);
4075 anthropic.api_key = key.to_string();
4076 anthropic.api_key_from_env = true;
4077 } else {
4078 self.apply_provider_instance_env_key("anthropic", key);
4079 }
4080 }
4081 }
4082 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4083 let key = key.trim();
4084 if !key.is_empty() {
4085 if self.provider_instances.is_empty() {
4086 let gemini = self
4087 .providers
4088 .gemini
4089 .get_or_insert_with(GeminiConfig::default);
4090 gemini.api_key = key.to_string();
4091 gemini.api_key_from_env = true;
4092 } else {
4093 self.apply_provider_instance_env_key("gemini", key);
4094 }
4095 }
4096 }
4097 }
4098
4099 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4100 for instance in self.provider_instances.values_mut().filter(|instance| {
4101 instance.provider_type == provider_type
4102 && crate::provider_instance_api_key_from_env(instance)
4103 }) {
4104 instance.api_key = key.to_string();
4105 instance.api_key_encrypted = None;
4106 }
4107 }
4108
4109 pub fn apply_runtime_env_overrides(&mut self) {
4114 self.apply_env_overrides();
4115 }
4116
4117 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4123 Self::from_data_dir_impl(data_dir, true, true)
4124 }
4125
4126 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4130 Self::from_data_dir_impl(data_dir, false, true)
4131 }
4132
4133 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4140 Self::from_data_dir_impl(data_dir, false, false)
4141 }
4142
4143 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4171 let connect_path = data_dir.join("connect.json");
4172 match std::fs::read_to_string(&connect_path) {
4173 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4174 Ok(connect) => {
4175 let legacy_key_present = !connect_config_is_empty(&self.connect);
4176 if legacy_key_present {
4177 tracing::warn!(
4178 "config.json still has a legacy `connect` key alongside \
4179 connect.json; connect.json takes precedence — dropping the \
4180 stale key from config.json now"
4181 );
4182 }
4183 self.connect = connect;
4184 if legacy_key_present {
4185 strip_legacy_connect_key_from_config_json(data_dir);
4186 }
4187 }
4188 Err(e) => {
4189 tracing::error!(
4190 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4191 connect config instead of falling back to a legacy config.json copy",
4192 connect_path,
4193 e
4194 );
4195 quarantine_corrupt_connect(&connect_path);
4196 self.connect = ConnectConfig::default();
4197 }
4198 },
4199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4200 if !connect_config_is_empty(&self.connect) {
4201 tracing::info!(
4202 "Migrating legacy `connect` config from config.json to a \
4203 standalone connect.json"
4204 );
4205 strip_legacy_connect_key_from_config_json(data_dir);
4209 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4210 tracing::error!("Failed to write connect.json during migration: {}", e);
4211 }
4212 }
4213 }
4214 Err(e) => {
4215 tracing::error!(
4216 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4217 connect_path,
4218 e
4219 );
4220 self.connect = ConnectConfig::default();
4221 }
4222 }
4223 }
4224
4225 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4228 serde_json::from_str::<Config>(content).map(|mut config| {
4229 config.hydrate_proxy_auth_from_encrypted();
4230 config.hydrate_provider_api_keys_from_encrypted();
4231 config.hydrate_provider_instance_api_keys_from_encrypted();
4232 config.hydrate_mcp_secrets_from_encrypted();
4233 config.hydrate_env_vars_from_encrypted();
4234 config.hydrate_cluster_fabric_from_encrypted();
4235 config.hydrate_broker_token_from_encrypted();
4236 config.hydrate_notifications_from_encrypted();
4237 config.hydrate_connect_platform_tokens_from_encrypted();
4238 config.normalize_tool_settings();
4239 config.normalize_skill_settings();
4240 config
4241 })
4242 }
4243
4244 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4250 let config_path = data_dir.join("config.json");
4251 for gen in 0..BAK_GENERATIONS {
4252 let backup = backup_path_for(&config_path, gen);
4253 let Ok(content) = std::fs::read_to_string(&backup) else {
4254 continue;
4255 };
4256 match Self::parse_and_hydrate(&content) {
4257 Ok(config) => {
4258 tracing::info!("Recovered configuration from {:?}", backup);
4259 return Some((config, gen));
4260 }
4261 Err(e) => {
4262 tracing::warn!(
4263 "Backup {:?} is unparseable ({}); trying an older generation",
4264 backup,
4265 e
4266 );
4267 }
4268 }
4269 }
4270 None
4271 }
4272
4273 const SALVAGE_MAX_KEYS: usize = 512;
4279
4280 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4307 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4310 let corrupt_obj = corrupt.as_object()?;
4311 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4312 tracing::warn!(
4313 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4314 corrupt_obj.len(),
4315 Self::SALVAGE_MAX_KEYS
4316 );
4317 return None;
4318 }
4319
4320 let mut base = Self::load_backup(data_dir)
4324 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4325 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4326 let base_obj = base.as_object_mut()?;
4327
4328 let mut salvaged: Vec<String> = Vec::new();
4329 for (key, value) in corrupt_obj {
4330 let previous = base_obj.insert(key.clone(), value.clone());
4331 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4335 salvaged.push(key.clone());
4336 } else {
4337 match previous {
4338 Some(prev) => {
4339 base_obj.insert(key.clone(), prev);
4340 }
4341 None => {
4342 base_obj.remove(key);
4343 }
4344 }
4345 }
4346 }
4347
4348 tracing::warn!(
4349 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4350 last-known-good/default value",
4351 salvaged.len(),
4352 salvaged.join(", ")
4353 );
4354
4355 let rebuilt = serde_json::to_string(&base).ok()?;
4359 Self::parse_and_hydrate(&rebuilt)
4360 .ok()
4361 .map(|config| (config, salvaged))
4362 }
4363
4364 pub fn get_model(&self) -> Option<String> {
4372 if self.features.provider_model_ref {
4373 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4374 return Some(model_ref.model.clone());
4375 }
4376 }
4377 let provider = self.effective_default_provider();
4378 if let Some(instance) = self.provider_instances.get(provider) {
4379 return instance
4380 .model
4381 .clone()
4382 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4383 }
4384 match provider {
4385 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4386 "anthropic" => self
4387 .providers
4388 .anthropic
4389 .as_ref()
4390 .and_then(|c| c.model.clone()),
4391 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4392 "copilot" => Some(
4393 self.providers
4394 .copilot
4395 .as_ref()
4396 .and_then(|c| c.model.clone())
4397 .unwrap_or_else(|| "gpt-4o".to_string()),
4398 ),
4399 _ => None,
4400 }
4401 }
4402
4403 pub fn get_fast_model(&self) -> Option<String> {
4411 if self.features.provider_model_ref {
4412 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4413 return Some(model_ref.model.clone());
4414 }
4415 }
4416 let provider = self.effective_default_provider();
4417 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4418 instance.fast_model.clone()
4419 } else {
4420 match provider {
4421 "openai" => self
4422 .providers
4423 .openai
4424 .as_ref()
4425 .and_then(|c| c.fast_model.clone()),
4426 "anthropic" => self
4427 .providers
4428 .anthropic
4429 .as_ref()
4430 .and_then(|c| c.fast_model.clone()),
4431 "gemini" => self
4432 .providers
4433 .gemini
4434 .as_ref()
4435 .and_then(|c| c.fast_model.clone()),
4436 "copilot" => self
4437 .providers
4438 .copilot
4439 .as_ref()
4440 .and_then(|c| c.fast_model.clone()),
4441 _ => None,
4442 }
4443 };
4444 fast.or_else(|| self.get_model())
4445 }
4446
4447 pub fn get_task_summary_model(&self) -> Option<String> {
4455 if self.features.provider_model_ref {
4456 if let Some(model_ref) = self
4457 .defaults
4458 .as_ref()
4459 .and_then(|d| d.task_summary.as_ref())
4460 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4461 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4462 {
4463 return Some(model_ref.model.clone());
4464 }
4465 }
4466
4467 self.get_fast_model()
4468 }
4469
4470 pub fn get_memory_background_model(&self) -> Option<String> {
4478 if self.features.provider_model_ref {
4479 if let Some(model_ref) = self
4480 .defaults
4481 .as_ref()
4482 .and_then(|d| d.memory_background.as_ref())
4483 {
4484 return Some(model_ref.model.clone());
4485 }
4486 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4487 return Some(model_ref.model.clone());
4488 }
4489 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4490 return Some(model_ref.model.clone());
4491 }
4492 }
4493 let configured = self
4494 .memory
4495 .as_ref()
4496 .and_then(|memory| memory.background_model.as_ref())
4497 .map(|value| value.trim())
4498 .filter(|value| !value.is_empty())
4499 .map(ToString::to_string);
4500 configured.or_else(|| self.get_fast_model())
4501 }
4502
4503 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4511 let raw = self
4512 .default_work_area
4513 .as_ref()
4514 .and_then(|config| config.path.as_ref())
4515 .map(|value| value.trim())
4516 .filter(|value| !value.is_empty())?;
4517
4518 let candidate = expand_user_path(raw);
4519 if candidate.is_absolute() {
4520 let canonical = std::fs::canonicalize(&candidate).ok();
4521 return canonical
4522 .as_ref()
4523 .filter(|path| path.is_dir())
4524 .map(|_| candidate.clone())
4525 .or_else(|| candidate.is_dir().then_some(candidate));
4526 }
4527
4528 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4529 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4530 canonical
4531 .as_ref()
4532 .filter(|path| path.is_dir())
4533 .map(|_| from_bamboo_dir.clone())
4534 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4535 .or_else(|| candidate.is_dir().then_some(candidate))
4536 }
4537
4538 pub fn get_vision_model(&self) -> Option<String> {
4544 if self.features.provider_model_ref {
4545 if let Some(model_ref) = self
4546 .defaults
4547 .as_ref()
4548 .and_then(|defaults| defaults.vision.as_ref())
4549 .or_else(|| {
4550 self.defaults
4551 .as_ref()
4552 .and_then(|defaults| defaults.fast.as_ref())
4553 })
4554 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4555 {
4556 return Some(model_ref.model.clone());
4557 }
4558 }
4559 let provider = self.effective_default_provider();
4560 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4561 instance.vision_model.clone()
4562 } else {
4563 match provider {
4564 "openai" => self
4565 .providers
4566 .openai
4567 .as_ref()
4568 .and_then(|c| c.vision_model.clone()),
4569 "anthropic" => self
4570 .providers
4571 .anthropic
4572 .as_ref()
4573 .and_then(|c| c.vision_model.clone()),
4574 "gemini" => self
4575 .providers
4576 .gemini
4577 .as_ref()
4578 .and_then(|c| c.vision_model.clone()),
4579 "copilot" => self
4580 .providers
4581 .copilot
4582 .as_ref()
4583 .and_then(|c| c.vision_model.clone()),
4584 _ => None,
4585 }
4586 };
4587 vision.or_else(|| self.get_fast_model())
4588 }
4589
4590 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4594 if self.features.provider_model_ref {
4595 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4596 return chat
4597 .reasoning_effort
4598 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4599 }
4600 }
4601 self.reasoning_effort_for_key(self.effective_default_provider())
4602 }
4603
4604 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4614 let trimmed = key.trim();
4615 if trimmed.is_empty() {
4616 return None;
4617 }
4618
4619 if let Some(instance) = self.provider_instances.get(trimmed) {
4621 return instance.reasoning_effort;
4622 }
4623
4624 match trimmed {
4626 "openai" => self
4627 .providers
4628 .openai
4629 .as_ref()
4630 .and_then(|c| c.reasoning_effort),
4631 "anthropic" => self
4632 .providers
4633 .anthropic
4634 .as_ref()
4635 .and_then(|c| c.reasoning_effort),
4636 "gemini" => self
4637 .providers
4638 .gemini
4639 .as_ref()
4640 .and_then(|c| c.reasoning_effort),
4641 "copilot" => self
4642 .providers
4643 .copilot
4644 .as_ref()
4645 .and_then(|c| c.reasoning_effort),
4646 "bodhi" => self
4647 .providers
4648 .bodhi
4649 .as_ref()
4650 .and_then(|c| c.reasoning_effort),
4651 _ => None,
4652 }
4653 }
4654
4655 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4662 self.tools
4663 .disabled
4664 .iter()
4665 .map(|name| name.trim())
4666 .filter(|name| !name.is_empty())
4667 .map(str::to_string)
4668 .collect()
4669 }
4670
4671 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4676 self.disabled_tool_references()
4677 .into_iter()
4678 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4679 .collect()
4680 }
4681
4682 pub fn normalize_tool_settings(&mut self) {
4684 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4685 }
4686
4687 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4689 self.skills
4690 .disabled
4691 .iter()
4692 .map(|id| id.trim())
4693 .filter(|id| !id.is_empty())
4694 .map(|id| id.to_string())
4695 .collect()
4696 }
4697
4698 pub fn normalize_skill_settings(&mut self) {
4700 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4701 }
4702
4703 pub fn normalize_plugin_trust_settings(&mut self) {
4711 self.plugin_trust.trusted_hosts = self
4712 .plugin_trust
4713 .trusted_hosts
4714 .iter()
4715 .map(|entry| entry.trim().to_ascii_lowercase())
4716 .filter(|entry| !entry.is_empty())
4717 .collect();
4718 }
4719
4720 pub fn effective_default_provider(&self) -> &str {
4725 self.default_provider_instance
4726 .as_deref()
4727 .unwrap_or(&self.provider)
4728 }
4729
4730 pub fn has_provider_instances(&self) -> bool {
4732 !self.provider_instances.is_empty()
4733 }
4734
4735 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4737 self.env_vars
4738 .iter()
4739 .filter(|e| !e.value.trim().is_empty())
4740 .map(|e| (e.name.clone(), e.value.clone()))
4741 .collect()
4742 }
4743
4744 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4745 self.env_vars
4746 .iter()
4747 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4748 .map(|entry| PromptSafeEnvVarEntry {
4749 name: entry.name.clone(),
4750 secret: entry.secret,
4751 description: entry
4752 .description
4753 .as_ref()
4754 .map(|value| value.trim().to_string())
4755 .filter(|value| !value.is_empty()),
4756 })
4757 .collect()
4758 }
4759
4760 pub fn publish_env_vars(&self) {
4762 let map = self.env_vars_as_map();
4763
4764 #[cfg(any(test, feature = "test-utils"))]
4765 if crate::test_support::env_vars_cache_override_is_active() {
4766 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4767 return;
4768 }
4769
4770 let mut env_guard = env_vars_cache().write().recover_poison();
4771 *env_guard = map;
4772
4773 let prompt_safe = self.prompt_safe_env_vars();
4774 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4775 *prompt_guard = prompt_safe;
4776 }
4777
4778 pub fn current_env_vars() -> HashMap<String, String> {
4780 #[cfg(any(test, feature = "test-utils"))]
4781 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4782 return env_vars;
4783 }
4784
4785 env_vars_cache().read().recover_poison().clone()
4786 }
4787
4788 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4790 #[cfg(any(test, feature = "test-utils"))]
4791 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4792 return env_vars;
4793 }
4794
4795 prompt_safe_env_vars_cache().read().recover_poison().clone()
4796 }
4797
4798 fn create_default() -> Self {
4800 Self::from_parts(
4801 ConfigValues {
4802 http_proxy: String::new(),
4803 https_proxy: String::new(),
4804 proxy_auth: None,
4805 proxy_auth_encrypted: None,
4806 proxy_auth_credential_ref: None,
4807 headless_auth: false,
4808 run_budget: RunBudgetConfig::default(),
4809 stream_timeout: StreamTimeoutConfig::default(),
4810 context_management: ContextManagementConfig::default(),
4811 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4812 provider: default_provider(),
4813 provider_instances: HashMap::new(),
4814 default_provider_instance: None,
4815 server: ServerConfig::default(),
4816 keyword_masking: KeywordMaskingConfig::default(),
4817 anthropic_model_mapping: AnthropicModelMapping::default(),
4818 gemini_model_mapping: GeminiModelMapping::default(),
4819 hooks: HooksConfig::default(),
4820 lifecycle_hooks: LifecycleHooksConfig::default(),
4821 tools: ToolsConfig::default(),
4822 skills: SkillsConfig::default(),
4823 env_vars: Vec::new(),
4824 default_work_area: None,
4825 worktrees: WorktreeSettings::default(),
4826 access_control: None,
4827 features: FeatureFlags::default(),
4828 defaults: None,
4829 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4830 notifications: NotificationsConfig::default(),
4831 connect: ConnectConfig::default(),
4832 plugin_trust: PluginTrustConfig::default(),
4833 extra: BTreeMap::new(),
4834 },
4835 None,
4836 SubagentsConfig::default(),
4837 ProviderConfigs::default(),
4838 )
4839 }
4840
4841 pub fn server_addr(&self) -> String {
4843 format!("{}:{}", self.server.bind, self.server.port)
4844 }
4845
4846 pub fn save(&self) -> Result<()> {
4848 self.save_to_dir(default_data_dir())
4849 }
4850
4851 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4854 self.memory.save_sync(data_dir)
4855 }
4856
4857 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4860 self.subagents.save_sync(data_dir)
4861 }
4862
4863 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4867 crate::config_module::ensure_legacy_module_layout(data_dir)?;
4868 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4869 let mut config = self.clone();
4870 config.clear_legacy_provider_aliases_for_instance_mode();
4871 config.refresh_provider_api_keys_encrypted()?;
4872 config.providers.save_sync(data_dir)
4873 }
4874
4875 pub(crate) fn prepare_provider_transaction_documents(
4879 &self,
4880 provider_document: &[u8],
4881 ) -> Result<(Vec<u8>, Vec<u8>)> {
4882 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4883 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4884 anyhow::bail!(
4885 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4886 status.source
4887 );
4888 }
4889
4890 let mut to_save = self.clone();
4891 to_save.clear_legacy_provider_aliases_for_instance_mode();
4892 to_save.extra.remove("data_dir");
4893 to_save.extra.remove("model");
4894 to_save.refresh_encrypted_secrets()?;
4895 to_save.ensure_provider_instance_credentials_isolated()?;
4896 to_save.sanitize_mcp_credential_refs_for_disk();
4897 to_save.sanitize_env_vars_for_disk();
4898 to_save.sanitize_notifications_for_disk();
4899 to_save.sanitize_cluster_fabric_for_disk();
4900 to_save.assign_connect_platform_ids();
4901 to_save.normalize_tool_settings();
4902 to_save.normalize_skill_settings();
4903
4904 let mut root = durable_root_value(to_save.values.clone())
4905 .context("Failed to serialize root config DTO to JSON")?;
4906 if let Some(object) = root.as_object_mut() {
4907 object.remove("connect");
4908 }
4909 let root = serde_json::to_vec_pretty(&root)?;
4910
4911 let mut providers = to_save.providers.0.clone();
4912 macro_rules! sanitize_provider {
4913 ($field:ident) => {
4914 if let Some(provider) = providers.$field.as_mut() {
4915 if !provider.api_key.trim().is_empty()
4916 && !provider.api_key_from_env
4917 && provider.credential_ref.is_none()
4918 {
4919 anyhow::bail!(
4920 "provider secret requires credential transaction before persistence"
4921 );
4922 }
4923 provider.api_key_encrypted = None;
4924 }
4925 };
4926 }
4927 sanitize_provider!(openai);
4928 sanitize_provider!(anthropic);
4929 sanitize_provider!(gemini);
4930 if let Some(provider) = providers.bodhi.as_mut() {
4931 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4932 anyhow::bail!("provider secret requires credential transaction before persistence");
4933 }
4934 provider.api_key_encrypted = None;
4935 }
4936
4937 let existing_provider_value = if provider_document.is_empty() {
4938 None
4939 } else {
4940 Some(
4941 serde_json::from_slice::<Value>(provider_document)
4942 .context("provider metadata document is invalid")?,
4943 )
4944 };
4945 let provider_value = match existing_provider_value {
4946 Some(Value::Object(mut envelope))
4947 if envelope.contains_key("schema_version")
4948 || envelope.contains_key("revision")
4949 || envelope.contains_key("data") =>
4950 {
4951 let revision = envelope
4952 .get("revision")
4953 .and_then(Value::as_u64)
4954 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4955 let schema_version = envelope
4956 .get("schema_version")
4957 .and_then(Value::as_u64)
4958 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4959 if schema_version != 1 || !envelope.contains_key("data") {
4960 anyhow::bail!("provider revision envelope is unsupported");
4961 }
4962 let revision = revision
4963 .checked_add(1)
4964 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4965 envelope.insert("revision".into(), Value::from(revision));
4966 envelope.insert("data".into(), serde_json::to_value(providers)?);
4967 Value::Object(envelope)
4968 }
4969 Some(_) | None => serde_json::to_value(providers)?,
4970 };
4971 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4972 }
4973
4974 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4978 self.recovery_status.as_ref()
4979 }
4980
4981 pub fn confirm_recovery(&mut self) {
4987 if let Some(status) = self.recovery_status.as_mut() {
4988 status.confirmed = true;
4989 }
4990 }
4991
4992 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5000 if self.recovery_status.is_none() {
5001 anyhow::bail!("No pending config-corruption recovery to confirm");
5002 }
5003 self.confirm_recovery();
5004 self.save_to_dir(data_dir)?;
5005 self.recovery_status = None;
5006 Ok(())
5007 }
5008
5009 pub fn assign_connect_platform_ids(&mut self) {
5025 for platform in &mut self.connect.platforms {
5026 if platform.id.is_none() {
5027 platform.id = Some(uuid::Uuid::new_v4().to_string());
5028 }
5029 }
5030 }
5031
5032 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5044 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5045 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5046 anyhow::bail!(
5047 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5048 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5049 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5050 status.source,
5051 status.quarantine_path,
5052 );
5053 }
5054 if self.proxy_auth_credential_ref.is_none()
5055 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5056 {
5057 anyhow::bail!(
5058 "proxy auth requires the isolated credential transaction before persistence"
5059 );
5060 }
5061 if self.env_vars.iter().any(|entry| {
5062 entry.secret
5063 && entry.credential_ref.is_none()
5064 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5065 }) {
5066 anyhow::bail!(
5067 "secret env vars require the isolated credential transaction before persistence"
5068 );
5069 }
5070 if [
5071 (
5072 &self.notifications.ntfy.token,
5073 &self.notifications.ntfy.token_encrypted,
5074 &self.notifications.ntfy.credential_ref,
5075 ),
5076 (
5077 &self.notifications.bark.device_key,
5078 &self.notifications.bark.device_key_encrypted,
5079 &self.notifications.bark.credential_ref,
5080 ),
5081 ]
5082 .iter()
5083 .any(|(plaintext, ciphertext, reference)| {
5084 reference.is_none()
5085 && (plaintext
5086 .as_deref()
5087 .is_some_and(|value| !value.trim().is_empty())
5088 || ciphertext.is_some())
5089 }) {
5090 anyhow::bail!(
5091 "notification secrets require the isolated credential transaction before persistence"
5092 );
5093 }
5094
5095 if crate::modular_authority_boundary_present(&data_dir)
5096 .context("Failed to inspect modular configuration authority boundary")?
5097 {
5098 crate::ConfigFacade::open_or_migrate(&data_dir)
5099 .context("Failed to recover modular configuration before persistence")?;
5100 crate::persist_facade_effective_config(&data_dir, self)
5101 .context("Failed to persist modular configuration sections")?;
5102 return Ok(());
5103 }
5104
5105 if crate::section_layout_is_active(&data_dir)
5106 .context("Failed to inspect modular configuration layout")?
5107 {
5108 crate::persist_facade_effective_config(&data_dir, self)
5109 .context("Failed to persist modular configuration sections")?;
5110 return Ok(());
5111 }
5112
5113 let path = data_dir.join("config.json");
5114
5115 if let Some(parent) = path.parent() {
5116 std::fs::create_dir_all(parent)
5117 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5118 }
5119
5120 let mut to_save = self.clone();
5121 to_save.clear_legacy_provider_aliases_for_instance_mode();
5122 to_save.extra.remove("data_dir");
5124 to_save.extra.remove("model");
5126 to_save.refresh_encrypted_secrets()?;
5129 to_save.ensure_provider_instance_credentials_isolated()?;
5130 to_save.sanitize_mcp_credential_refs_for_disk();
5131 to_save.sanitize_env_vars_for_disk();
5132 to_save.sanitize_notifications_for_disk();
5133 to_save.sanitize_cluster_fabric_for_disk();
5134 to_save.assign_connect_platform_ids();
5135 to_save.normalize_tool_settings();
5136 to_save.normalize_skill_settings();
5137
5138 let mut config_value = durable_root_value(to_save.values.clone())
5148 .context("Failed to serialize root config DTO to JSON")?;
5149 if let Some(obj) = config_value.as_object_mut() {
5150 obj.remove("connect");
5151 }
5152 let content = serde_json::to_string_pretty(&config_value)
5153 .context("Failed to serialize config to JSON")?;
5154
5155 to_save.memory.save_sync(&data_dir)?;
5162 to_save.subagents.save_sync(&data_dir)?;
5163 to_save.providers.save_sync(&data_dir)?;
5164
5165 if path.exists()
5172 && std::fs::read_to_string(&path)
5173 .ok()
5174 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5175 {
5176 rotate_backups(&path, BAK_GENERATIONS);
5180 let backup = backup_path_for(&path, 0);
5181 let backup_result = std::fs::read(&path).and_then(|bytes| {
5182 let mut value: Value = serde_json::from_slice(&bytes)
5183 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5184 if sanitize_ref_backed_mcp_json(&mut value) {
5185 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5186 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5187 })?;
5188 write_atomic(&backup, &sanitized)
5189 } else {
5190 std::fs::copy(&path, &backup).map(|_| ())
5191 }
5192 });
5193 if let Err(e) = backup_result {
5194 tracing::warn!("Failed to back up config.json before save: {}", e);
5195 }
5196 scrub_ref_backed_mcp_from_config_backups(&path);
5197 }
5198
5199 write_atomic(&path, content.as_bytes())
5200 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5201
5202 save_connect_config(&to_save.connect, &data_dir)?;
5203
5204 Ok(())
5205 }
5206}
5207
5208fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5213 let Some(object) = root.as_object_mut() else {
5214 return false;
5215 };
5216 let Some(mcp) = (if object.contains_key("mcpServers") {
5217 object.get_mut("mcpServers")
5218 } else {
5219 object.get_mut("mcp")
5220 }) else {
5221 return false;
5222 };
5223 let mut changed = false;
5224 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5225 for server in servers {
5226 if let Some(object) = server.as_object_mut() {
5227 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5228 {
5229 changed |= sanitize_ref_backed_mcp_transport(transport);
5230 }
5231 }
5232 }
5233 } else if let Some(servers) = mcp.as_object_mut() {
5234 for server in servers.values_mut() {
5235 if let Some(object) = server.as_object_mut() {
5236 changed |= sanitize_ref_backed_mcp_transport(object);
5237 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5238 {
5239 changed |= sanitize_ref_backed_mcp_transport(transport);
5240 }
5241 }
5242 }
5243 }
5244 changed
5245}
5246
5247fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5248 let mut changed = false;
5249 let env_names = object
5250 .get("env_credential_refs")
5251 .and_then(Value::as_object)
5252 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5253 .unwrap_or_default();
5254 for name in env_names {
5255 for field in ["env", "env_encrypted"] {
5256 if object
5257 .get_mut(field)
5258 .and_then(Value::as_object_mut)
5259 .and_then(|values| values.remove(&name))
5260 .is_some()
5261 {
5262 changed = true;
5263 }
5264 }
5265 }
5266 let header_names = object
5267 .get("header_credential_refs")
5268 .and_then(Value::as_object)
5269 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5270 .unwrap_or_default();
5271 for name in header_names {
5272 for field in ["headers", "headers_encrypted"] {
5273 if object
5274 .get_mut(field)
5275 .and_then(Value::as_object_mut)
5276 .and_then(|values| values.remove(&name))
5277 .is_some()
5278 {
5279 changed = true;
5280 }
5281 }
5282 }
5283 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5284 for header in headers {
5285 let Some(header) = header.as_object_mut() else {
5286 continue;
5287 };
5288 if header.get("credential_ref").is_some_and(Value::is_string) {
5289 changed |= header.remove("value").is_some();
5290 changed |= header.remove("value_encrypted").is_some();
5291 }
5292 }
5293 }
5294 changed
5295}
5296
5297fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5298 for generation in 0..BAK_GENERATIONS {
5299 let backup = backup_path_for(path, generation);
5300 let Ok(bytes) = std::fs::read(&backup) else {
5301 continue;
5302 };
5303 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5304 continue;
5305 };
5306 if sanitize_ref_backed_mcp_json(&mut value) {
5307 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5308 if let Err(error) = write_atomic(&backup, &sanitized) {
5309 tracing::warn!(
5310 "Failed to scrub MCP credentials from {:?}: {}",
5311 backup,
5312 error
5313 );
5314 }
5315 }
5316 }
5317 }
5318}
5319
5320fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5330 let path = data_dir.join("connect.json");
5331 if connect_config_is_empty(connect) && !path.exists() {
5332 return Ok(());
5333 }
5334
5335 if path.exists() {
5336 let backup = path.with_extension("json.bak");
5337 if let Err(e) = std::fs::copy(&path, &backup) {
5338 tracing::warn!("Failed to back up connect.json before save: {}", e);
5339 }
5340 }
5341
5342 let content = serde_json::to_string_pretty(connect)
5343 .context("Failed to serialize connect config to JSON")?;
5344 write_atomic(&path, content.as_bytes())
5345 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5346 Ok(())
5347}
5348
5349fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5367 let config_path = data_dir.join("config.json");
5368 let content = match std::fs::read_to_string(&config_path) {
5369 Ok(content) => content,
5370 Err(e) => {
5371 tracing::error!(
5372 "Failed to read config.json to strip legacy `connect` key: {}",
5373 e
5374 );
5375 return;
5376 }
5377 };
5378 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5379 Ok(value) => value,
5380 Err(e) => {
5381 tracing::error!(
5382 "Failed to parse config.json to strip legacy `connect` key: {}",
5383 e
5384 );
5385 return;
5386 }
5387 };
5388 let Some(obj) = value.as_object_mut() else {
5389 return;
5390 };
5391 if obj.remove("connect").is_none() {
5392 return;
5395 }
5396 let rewritten = match serde_json::to_string_pretty(&value) {
5397 Ok(rewritten) => rewritten,
5398 Err(e) => {
5399 tracing::error!(
5400 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5401 e
5402 );
5403 return;
5404 }
5405 };
5406 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5407 tracing::error!(
5408 "Failed to write config.json after stripping legacy `connect` key: {}",
5409 e
5410 );
5411 }
5412}
5413
5414fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5451 let config_path = data_dir.join("config.json");
5452 for gen in 0..BAK_GENERATIONS {
5453 let backup = backup_path_for(&config_path, gen);
5454 let content = match std::fs::read_to_string(&backup) {
5455 Ok(content) => content,
5456 Err(e) => {
5457 if e.kind() != std::io::ErrorKind::NotFound {
5458 tracing::warn!(
5459 "Failed to read {:?} while scanning for legacy connect data ({}); \
5460 leaving it untouched",
5461 backup,
5462 e
5463 );
5464 }
5465 continue;
5466 }
5467 };
5468 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5469 Ok(value) => value,
5470 Err(e) => {
5471 tracing::warn!(
5472 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5473 ({}); left untouched (never deleted)",
5474 backup,
5475 e
5476 );
5477 continue;
5478 }
5479 };
5480 let Some(obj) = value.as_object_mut() else {
5481 continue;
5484 };
5485 if obj.remove("connect").is_none() {
5486 continue;
5489 }
5490 let rewritten = match serde_json::to_string_pretty(&value) {
5491 Ok(rewritten) => rewritten,
5492 Err(e) => {
5493 tracing::error!(
5494 "Failed to serialize {:?} after stripping legacy connect data: {}",
5495 backup,
5496 e
5497 );
5498 continue;
5499 }
5500 };
5501 match write_atomic(&backup, rewritten.as_bytes()) {
5502 Ok(()) => tracing::info!(
5503 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5504 backup
5505 ),
5506 Err(e) => tracing::error!(
5507 "Failed to write {:?} after stripping legacy connect data: {}",
5508 backup,
5509 e
5510 ),
5511 }
5512 }
5513}
5514
5515fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5530 let backup = connect_path.with_extension("json.bak");
5531 match std::fs::rename(connect_path, &backup) {
5532 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5533 Err(e) => {
5534 tracing::warn!(
5535 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5536 backup,
5537 e
5538 );
5539 if let Err(e) = std::fs::copy(connect_path, &backup) {
5540 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5541 return;
5542 }
5543 if let Err(e) = std::fs::remove_file(connect_path) {
5544 tracing::error!(
5545 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5546 original {:?}: {}",
5547 backup,
5548 connect_path,
5549 e
5550 );
5551 }
5552 }
5553 }
5554}
5555
5556const QUARANTINE_KEEP: usize = 5;
5559
5560fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5569 let nanos = std::time::SystemTime::now()
5570 .duration_since(std::time::UNIX_EPOCH)
5571 .map(|d| d.as_nanos())
5572 .unwrap_or(0);
5573 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5577 let mut dedup = 1u32;
5578 while quarantine.exists() {
5579 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5580 dedup += 1;
5581 }
5582 let result = match std::fs::copy(config_path, &quarantine) {
5583 Ok(_) => {
5584 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5585 Some(quarantine)
5586 }
5587 Err(e) => {
5588 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5589 None
5590 }
5591 };
5592 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5593 result
5594}
5595
5596fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5599 let Some(dir) = config_path.parent() else {
5600 return;
5601 };
5602 let prefix = "config.json.corrupted.";
5603 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5604 Ok(entries) => entries
5605 .filter_map(|e| e.ok())
5606 .map(|e| e.path())
5607 .filter(|p| {
5608 p.file_name()
5609 .and_then(|n| n.to_str())
5610 .is_some_and(|n| n.starts_with(prefix))
5611 })
5612 .collect(),
5613 Err(_) => return,
5614 };
5615 if quarantines.len() <= keep {
5616 return;
5617 }
5618 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5620 let remove = quarantines.len() - keep;
5621 for stale in quarantines.into_iter().take(remove) {
5622 if let Err(e) = std::fs::remove_file(&stale) {
5623 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5624 }
5625 }
5626}
5627
5628const BAK_GENERATIONS: usize = 3;
5631
5632fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5634 if gen == 0 {
5635 config_path.with_extension("json.bak")
5636 } else {
5637 config_path.with_extension(format!("json.bak.{gen}"))
5638 }
5639}
5640
5641fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5647 for gen in (1..generations).rev() {
5648 let from = backup_path_for(config_path, gen - 1);
5649 let to = backup_path_for(config_path, gen);
5650 if from.exists() {
5651 if let Err(e) = std::fs::rename(&from, &to) {
5652 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5653 }
5654 }
5655 }
5656}
5657
5658pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5659 crate::config_store::AtomicFileStore::new(path)
5660 .write_bytes_without_backup(content)
5661 .map_err(|error| match error {
5662 crate::config_store::ConfigStoreError::Io(error) => error,
5663 other => std::io::Error::other(other),
5664 })
5665}
5666
5667#[cfg(test)]
5668mod tests {
5669 use super::*;
5670 use std::ffi::OsString;
5671 use std::path::PathBuf;
5672 use std::sync::Mutex;
5673 use std::time::{SystemTime, UNIX_EPOCH};
5674
5675 #[test]
5676 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5677 let input = serde_json::json!({
5678 "disabled": ["bash"],
5679 "plugin_runtime": {
5680 "timeout_ms": 5_000,
5681 "sandbox": true
5682 },
5683 "future_flag": "enabled"
5684 });
5685
5686 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5687 assert_eq!(config.disabled, vec!["bash"]);
5688 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5689 assert_eq!(config.extra["future_flag"], "enabled");
5690 assert_eq!(serde_json::to_value(config).unwrap(), input);
5691 }
5692
5693 #[test]
5694 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5695 let input = serde_json::json!({
5696 "tool_extension": {
5697 "mode": "strict",
5698 "options": ["one", "two"]
5699 }
5700 });
5701 let config: Config = serde_json::from_value(serde_json::json!({
5702 "tools": input.clone()
5703 }))
5704 .unwrap();
5705
5706 assert!(config.tools.disabled.is_empty());
5707 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5708 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5709
5710 let temp_home = TempHome::new();
5711 config.save_to_dir(temp_home.path.clone()).unwrap();
5712 let persisted: Value =
5713 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5714 .unwrap();
5715 assert_eq!(persisted["tools"], input);
5716 }
5717
5718 #[test]
5719 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5720 let input = serde_json::json!({
5721 "external_catalog": {
5722 "path": "/opt/bamboo/skills",
5723 "refresh": false
5724 },
5725 "schema_version": 2
5726 });
5727
5728 let config: Config = serde_json::from_value(serde_json::json!({
5729 "skills": input.clone()
5730 }))
5731 .unwrap();
5732 assert!(config.skills.disabled.is_empty());
5733 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5734 assert_eq!(config.skills.extra["schema_version"], 2);
5735 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5736
5737 let temp_home = TempHome::new();
5738 config.save_to_dir(temp_home.path.clone()).unwrap();
5739 let persisted: Value =
5740 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5741 .unwrap();
5742 assert_eq!(persisted["skills"], input);
5743 }
5744
5745 #[test]
5746 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5747 let config: Config = serde_json::from_value(serde_json::json!({
5748 "hooks": {
5749 "image_fallback": {"enabled": true, "mode": "placeholder"}
5750 },
5751 "lifecycle_hooks": {
5752 "enabled": true,
5753 "PreToolUse": [{
5754 "matcher": "bash|write_file",
5755 "hooks": [{"type": "command", "command": "guard.sh"}]
5756 }],
5757 "SessionStart": [{
5758 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5759 }]
5760 }
5761 }))
5762 .expect("lifecycle hook config should deserialize");
5763
5764 assert!(config.lifecycle_hooks.enabled);
5765 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5766 assert!(
5767 config.lifecycle_hooks.pre_tool_use[0].enabled,
5768 "legacy groups without an enabled flag remain active"
5769 );
5770 assert_eq!(
5771 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5772 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5773 );
5774 assert_eq!(
5775 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5776 25
5777 );
5778
5779 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5780 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5781 assert_eq!(
5782 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5783 "bash|write_file"
5784 );
5785 assert_eq!(
5786 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5787 "command"
5788 );
5789 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5790 .get("enabled")
5791 .is_none());
5792 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5793 .get("timeout_ms")
5794 .is_none());
5795 assert!(json.get("hooks").is_some());
5796 }
5797
5798 #[test]
5799 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5800 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5801 "type": "script",
5802 "path": ".bamboo/hooks/check.js"
5803 }))
5804 .expect("script lifecycle hook should deserialize");
5805
5806 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5807 assert!(matches!(
5808 handler,
5809 LifecycleHookHandler::Script {
5810 runner: LifecycleScriptRunner::Auto,
5811 ..
5812 }
5813 ));
5814 let json = serde_json::to_value(handler).expect("script hook should serialize");
5815 assert_eq!(json["type"], "script");
5816 assert!(json.get("timeout_ms").is_none());
5817 assert!(json.get("runner").is_none());
5818 }
5819
5820 #[test]
5821 fn script_runner_support_is_extension_aware() {
5822 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5823 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5824 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5825 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5826 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5827 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5828 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5829 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5830 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5831 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5832 }
5833
5834 #[test]
5835 fn script_runner_names_round_trip_through_config_json() {
5836 for (runner, name) in [
5837 (LifecycleScriptRunner::Auto, "auto"),
5838 (LifecycleScriptRunner::Node, "node"),
5839 (LifecycleScriptRunner::Bun, "bun"),
5840 (LifecycleScriptRunner::Python, "python"),
5841 (LifecycleScriptRunner::Bash, "bash"),
5842 (LifecycleScriptRunner::PowerShell, "powershell"),
5843 (LifecycleScriptRunner::Cmd, "cmd"),
5844 ] {
5845 let json = serde_json::to_value(runner).unwrap();
5846 assert_eq!(json, name);
5847 assert_eq!(
5848 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5849 runner
5850 );
5851 }
5852 }
5853
5854 #[test]
5855 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5856 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5857 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5858 let json = serde_json::to_value(&config).unwrap();
5859 assert!(json.get("lifecycle_hooks").is_none());
5860 }
5861
5862 #[test]
5863 fn stream_timeout_defaults_are_safe_and_back_compatible() {
5864 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5865 .expect("legacy config without stream_timeout should load");
5866 let values = ConfigValues::from(root);
5867
5868 assert_eq!(
5869 values.stream_timeout,
5870 StreamTimeoutConfig {
5871 transport_idle_timeout_secs: 120,
5872 first_semantic_timeout_secs: 600,
5873 semantic_idle_timeout_secs: 600,
5874 }
5875 );
5876 values
5877 .stream_timeout
5878 .validate()
5879 .expect("defaults are valid");
5880 }
5881
5882 #[test]
5883 fn stream_timeout_round_trips_through_persistence_dto() {
5884 let values = ConfigValues {
5885 stream_timeout: StreamTimeoutConfig {
5886 transport_idle_timeout_secs: 45,
5887 first_semantic_timeout_secs: 900,
5888 semantic_idle_timeout_secs: 300,
5889 },
5890 ..ConfigValues::default()
5891 };
5892
5893 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5894 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5895 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5896 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5897
5898 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5899 assert_eq!(
5900 ConfigValues::from(root).stream_timeout,
5901 StreamTimeoutConfig {
5902 transport_idle_timeout_secs: 45,
5903 first_semantic_timeout_secs: 900,
5904 semantic_idle_timeout_secs: 300,
5905 }
5906 );
5907 }
5908
5909 #[test]
5910 fn context_management_defaults_to_summary_and_is_omitted() {
5911 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5912 assert_eq!(
5913 config.context_management.strategy,
5914 ContextManagementStrategy::Summary
5915 );
5916 assert_eq!(
5917 config
5918 .context_management
5919 .retrieval_window
5920 .min_recent_user_turns,
5921 3
5922 );
5923 assert_eq!(
5924 config.context_management.retrieval_target_usage_percent(),
5925 60
5926 );
5927 let json = serde_json::to_value(&config).unwrap();
5928 assert!(json.get("context_management").is_none());
5929 }
5930
5931 #[test]
5932 fn retrieval_window_context_management_round_trips() {
5933 let json = serde_json::json!({
5934 "context_management": {
5935 "strategy": "retrieval_window",
5936 "retrieval_window": {
5937 "min_recent_user_turns": 4,
5938 "trigger_usage_ratio": 0.82,
5939 "target_usage_ratio": 0.61,
5940 "history_tool_required": true,
5941 "fallback_strategy": "summary"
5942 }
5943 }
5944 });
5945 let root: ConfigRoot = serde_json::from_value(json).unwrap();
5946 let values = ConfigValues::from(root);
5947 assert_eq!(
5948 values.context_management.strategy,
5949 ContextManagementStrategy::RetrievalWindow
5950 );
5951 assert_eq!(
5952 values.context_management.retrieval_window.fallback_strategy,
5953 ContextManagementFallbackStrategy::Summary
5954 );
5955
5956 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5957 assert_eq!(
5958 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
5959 4
5960 );
5961 assert_eq!(
5962 persisted["context_management"]["strategy"],
5963 "retrieval_window"
5964 );
5965 }
5966
5967 #[test]
5968 fn message_summary_context_management_round_trips_without_changing_defaults() {
5969 let root: ConfigRoot = serde_json::from_value(serde_json::json!({
5970 "context_management": {
5971 "strategy": "message_summary"
5972 }
5973 }))
5974 .unwrap();
5975 let values = ConfigValues::from(root);
5976 assert_eq!(
5977 values.context_management.strategy,
5978 ContextManagementStrategy::MessageSummary
5979 );
5980 assert_eq!(
5981 values.context_management.retrieval_window,
5982 RetrievalWindowContextConfig::default()
5983 );
5984
5985 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5986 assert_eq!(
5987 persisted["context_management"]["strategy"],
5988 "message_summary"
5989 );
5990 let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
5991 assert_eq!(
5992 ConfigValues::from(reloaded).context_management.strategy,
5993 ContextManagementStrategy::MessageSummary
5994 );
5995 assert_eq!(
5996 ContextManagementConfig::default().strategy,
5997 ContextManagementStrategy::Summary
5998 );
5999 }
6000
6001 #[test]
6002 fn retrieval_window_context_management_rejects_unsafe_policy() {
6003 for invalid in [
6004 serde_json::json!({
6005 "strategy": "retrieval_window",
6006 "retrieval_window": {"min_recent_user_turns": 0}
6007 }),
6008 serde_json::json!({
6009 "strategy": "retrieval_window",
6010 "retrieval_window": {
6011 "target_usage_ratio": 0.8,
6012 "trigger_usage_ratio": 0.8
6013 }
6014 }),
6015 serde_json::json!({
6016 "strategy": "retrieval_window",
6017 "retrieval_window": {
6018 "target_usage_ratio": 0.005,
6019 "trigger_usage_ratio": 0.006
6020 }
6021 }),
6022 serde_json::json!({
6023 "strategy": "retrieval_window",
6024 "retrieval_window": {"history_tool_required": false}
6025 }),
6026 ] {
6027 serde_json::from_value::<ContextManagementConfig>(invalid)
6028 .expect_err("unsafe retrieval-window policy must fail closed");
6029 }
6030 }
6031
6032 #[test]
6033 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6034 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6035 "provider_type": "openai",
6036 "enabled": true
6037 }))
6038 .unwrap();
6039 let mut config = Config::default();
6040 config.values.provider = "gemini".to_string();
6041 config
6042 .provider_instances
6043 .insert("work".to_string(), instance);
6044 config.default_provider_instance = Some("work".to_string());
6045 config.providers_mut().openai = Some(OpenAIConfig::default());
6046
6047 let json = serde_json::to_value(&config).unwrap();
6048 assert_eq!(json["provider"], "gemini");
6049 assert!(json["providers"]["openai"].is_object());
6050 assert_eq!(json["default_provider_instance"], "work");
6051
6052 let round_trip: Config = serde_json::from_value(json).unwrap();
6053 assert_eq!(round_trip.provider, "gemini");
6054 assert_eq!(
6055 round_trip.default_provider_instance.as_deref(),
6056 Some("work")
6057 );
6058 assert!(round_trip.provider_instances.contains_key("work"));
6059 assert!(round_trip.providers().openai.is_some());
6060 }
6061
6062 #[test]
6063 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6064 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6065 let dir = tempfile::tempdir().unwrap();
6066 let mut config = Config::default();
6067 config.values.provider = "anthropic".to_string();
6068 config.provider_instances.insert(
6069 "work".to_string(),
6070 serde_json::from_value(serde_json::json!({
6071 "provider_type": "openai",
6072 "model": "gpt-instance",
6073 "enabled": true,
6074 "future_instance_metadata": "preserved"
6075 }))
6076 .unwrap(),
6077 );
6078 config.default_provider_instance = Some("work".to_string());
6079 config.features.provider_model_ref = true;
6080 config.providers_mut().openai = Some(OpenAIConfig::default());
6081 config.providers_mut().anthropic = Some(AnthropicConfig::default());
6082 config
6083 .providers_mut()
6084 .extra
6085 .insert("provider".to_string(), serde_json::json!("anthropic"));
6086 config.providers_mut().extra.insert(
6087 "future_provider".to_string(),
6088 serde_json::json!({"kept": true}),
6089 );
6090
6091 let (root_bytes, provider_bytes) =
6092 config.prepare_provider_transaction_documents(&[]).unwrap();
6093 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6094 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6095 assert!(root.get("provider").is_none());
6096 assert!(root.get("providers").is_none());
6097 assert_eq!(root["default_provider_instance"], "work");
6098 assert_eq!(
6099 root["provider_instances"]["work"]["future_instance_metadata"],
6100 "preserved"
6101 );
6102 assert!(providers.get("provider").is_none());
6103 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6104 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6105 }
6106 assert_eq!(providers["future_provider"]["kept"], true);
6107
6108 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6109 let saved_root: Value =
6110 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6111 .unwrap();
6112 let saved_providers: Value =
6113 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6114 .unwrap();
6115 assert!(saved_root.get("provider").is_none());
6116 assert!(saved_root.get("providers").is_none());
6117 assert_eq!(saved_root["default_provider_instance"], "work");
6118 assert!(saved_providers.get("openai").is_none());
6119 assert!(saved_providers.get("anthropic").is_none());
6120 assert!(saved_providers.get("provider").is_none());
6121 assert_eq!(saved_providers["future_provider"]["kept"], true);
6122
6123 config.providers_mut().gemini = Some(GeminiConfig::default());
6124 config.save_providers_to_dir(dir.path()).unwrap();
6125 let provider_only: Value =
6126 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6127 .unwrap();
6128 assert!(provider_only.get("gemini").is_none());
6129 assert!(provider_only.get("provider").is_none());
6130 assert_eq!(provider_only["future_provider"]["kept"], true);
6131 }
6132
6133 #[test]
6134 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6135 let mut config = Config::default();
6136 config.values.provider = "openai".to_string();
6137 config.providers_mut().openai = Some(OpenAIConfig {
6138 model: Some("gpt-legacy".to_string()),
6139 ..OpenAIConfig::default()
6140 });
6141 config.provider_instances.insert(
6142 "work".to_string(),
6143 serde_json::from_value(serde_json::json!({
6144 "provider_type": "copilot",
6145 "enabled": true
6146 }))
6147 .unwrap(),
6148 );
6149 config.default_provider_instance = Some("openai".to_string());
6150
6151 let (root_bytes, provider_bytes) =
6152 config.prepare_provider_transaction_documents(&[]).unwrap();
6153 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6154 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6155 assert_eq!(root["provider"], "openai");
6156 assert_eq!(root["default_provider_instance"], "openai");
6157 assert!(root["provider_instances"]["work"].is_object());
6158 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6159 }
6160
6161 #[test]
6162 fn persistence_keeps_legacy_provider_without_instance_default() {
6163 let values = ConfigValues {
6164 provider: "gemini".to_string(),
6165 ..ConfigValues::default()
6166 };
6167
6168 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6169 assert_eq!(json["provider"], "gemini");
6170 }
6171
6172 #[test]
6173 fn stream_timeout_rejects_zero_and_unbounded_values() {
6174 for (field, invalid) in [
6175 ("transport_idle_timeout_secs", 0),
6176 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6177 ("semantic_idle_timeout_secs", 0),
6178 ] {
6179 let mut timeout = serde_json::json!({});
6180 timeout[field] = serde_json::json!(invalid);
6181 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6182 .expect_err("invalid timeout must be rejected");
6183 assert!(error.to_string().contains("stream timeout must be between"));
6184 }
6185 }
6186
6187 struct EnvVarGuard {
6188 key: &'static str,
6189 previous: Option<OsString>,
6190 }
6191
6192 impl EnvVarGuard {
6193 fn set(key: &'static str, value: &str) -> Self {
6194 let previous = std::env::var_os(key);
6195 std::env::set_var(key, value);
6196 Self { key, previous }
6197 }
6198
6199 fn unset(key: &'static str) -> Self {
6200 let previous = std::env::var_os(key);
6201 std::env::remove_var(key);
6202 Self { key, previous }
6203 }
6204 }
6205
6206 impl Drop for EnvVarGuard {
6207 fn drop(&mut self) {
6208 match &self.previous {
6209 Some(value) => std::env::set_var(self.key, value),
6210 None => std::env::remove_var(self.key),
6211 }
6212 }
6213 }
6214
6215 #[test]
6216 fn run_budget_config_merge_is_tighten_only_per_field() {
6217 let config_default = RunBudgetConfig {
6218 max_total_tokens: Some(100_000),
6219 max_tool_calls: Some(500),
6220 max_subagents: Some(10),
6221 max_rounds: Some(200),
6222 };
6223
6224 assert_eq!(
6226 config_default.merged_with_override(None),
6227 config_default,
6228 "no override falls back to the config default entirely"
6229 );
6230
6231 let tighten_one = RunBudgetConfig {
6234 max_total_tokens: Some(5_000),
6235 max_tool_calls: None,
6236 max_subagents: None,
6237 max_rounds: None,
6238 };
6239 let merged = config_default.merged_with_override(Some(&tighten_one));
6240 assert_eq!(merged.max_total_tokens, Some(5_000));
6241 assert_eq!(merged.max_tool_calls, Some(500));
6242 assert_eq!(merged.max_subagents, Some(10));
6243 assert_eq!(merged.max_rounds, Some(200));
6244
6245 let loosen_attempt = RunBudgetConfig {
6248 max_total_tokens: Some(999_999_999),
6249 max_tool_calls: Some(10_000),
6250 max_subagents: Some(1_000),
6251 max_rounds: Some(100_000),
6252 };
6253 assert_eq!(
6254 config_default.merged_with_override(Some(&loosen_attempt)),
6255 config_default,
6256 "looser per-request values must be clamped to the config ceiling"
6257 );
6258
6259 let empty_override = RunBudgetConfig::default();
6263 assert_eq!(
6264 config_default.merged_with_override(Some(&empty_override)),
6265 config_default,
6266 "an all-absent override body keeps every configured ceiling"
6267 );
6268
6269 let unlimited_default = RunBudgetConfig::default();
6273 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6274 assert_eq!(merged.max_total_tokens, Some(5_000));
6275 assert_eq!(merged.max_tool_calls, None);
6276 assert_eq!(merged.max_subagents, None);
6277 assert_eq!(merged.max_rounds, None);
6278
6279 let tighten_rounds = RunBudgetConfig {
6282 max_total_tokens: None,
6283 max_tool_calls: None,
6284 max_subagents: None,
6285 max_rounds: Some(20),
6286 };
6287 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6288 assert_eq!(merged.max_rounds, Some(20));
6289 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6290 assert_eq!(
6291 merged.max_rounds,
6292 Some(20),
6293 "a stricter per-request round cap wins over the config default"
6294 );
6295 }
6296
6297 #[test]
6298 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6299 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6300 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6301 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6302 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6303
6304 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6305 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6306 assert_eq!(cfg.max_total_tokens, Some(250_000));
6307 assert_eq!(
6308 cfg.max_tool_calls, None,
6309 "absent field defaults to unlimited"
6310 );
6311 assert_eq!(
6312 cfg.max_rounds, None,
6313 "absent round cap defaults to unlimited"
6314 );
6315
6316 assert_eq!(cfg.max_subagents, Some(3));
6317
6318 let json = r#"{ "max_rounds": 250 }"#;
6320 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6321 assert_eq!(cfg.max_rounds, Some(250));
6322 let round_tripped: RunBudgetConfig =
6323 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6324 assert_eq!(round_tripped.max_rounds, Some(250));
6325 assert_eq!(round_tripped.max_total_tokens, None);
6326
6327 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6331 assert_eq!(empty, "{}");
6332 }
6333
6334 #[test]
6335 fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6336 let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6337 assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6338 assert!(!serde_json::to_string(&old)
6339 .unwrap()
6340 .contains("max_spawn_depth"));
6341 for depth in [0, 1, 8] {
6342 let config: SubagentsConfig =
6343 serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6344 assert_eq!(config.effective_max_spawn_depth(), depth);
6345 assert_eq!(
6346 serde_json::to_value(config).unwrap()["max_spawn_depth"],
6347 depth
6348 );
6349 }
6350 for invalid in [
6351 serde_json::json!(-1),
6352 serde_json::json!(1.5),
6353 serde_json::json!("4"),
6354 ] {
6355 assert!(serde_json::from_value::<SubagentsConfig>(
6356 serde_json::json!({"max_spawn_depth": invalid}),
6357 )
6358 .is_err());
6359 }
6360 }
6361
6362 #[test]
6363 fn subagents_config_without_remote_placements_deserializes_empty() {
6364 let json = r#"{ "max_concurrent": 4 }"#;
6367 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6368 assert_eq!(cfg.max_concurrent, Some(4));
6369 assert!(cfg.remote_placements.is_empty());
6370 let back = serde_json::to_string(&cfg).unwrap();
6372 assert!(
6373 !back.contains("remote_placements"),
6374 "empty vec is skipped: {back}"
6375 );
6376 }
6377
6378 #[test]
6379 fn remote_actor_placement_round_trips() {
6380 let json = r#"{
6381 "remote_placements": [
6382 {
6383 "role": "explorer",
6384 "endpoint": "wss://gpu-host:8443",
6385 "token_env": "WORKER_TOKEN",
6386 "ca_cert_file": "/etc/bamboo/worker.pem"
6387 },
6388 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6389 ]
6390 }"#;
6391 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6392 assert_eq!(cfg.remote_placements.len(), 2);
6393 let p0 = &cfg.remote_placements[0];
6394 assert_eq!(p0.role, "explorer");
6395 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6396 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6397 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6398 let p1 = &cfg.remote_placements[1];
6400 assert_eq!(p1.role, "writer");
6401 assert!(p1.token_env.is_none());
6402 assert!(p1.ca_cert_file.is_none());
6403
6404 let back = serde_json::to_string(&cfg).unwrap();
6405 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6406 assert_eq!(cfg, reparsed, "round-trip is stable");
6407 assert!(!back.contains("\"token_env\":null"));
6408 assert!(!back.contains("\"ca_cert_file\":null"));
6409 }
6410
6411 #[test]
6412 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6413 let json = r#"{ "max_concurrent": 4 }"#;
6416 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6417 assert!(cfg.schedulable_placements.is_empty());
6418 let back = serde_json::to_string(&cfg).unwrap();
6420 assert!(
6421 !back.contains("schedulable_placements"),
6422 "empty vec is skipped: {back}"
6423 );
6424 }
6425
6426 #[test]
6427 fn schedulable_placement_round_trips() {
6428 let json = r#"{
6429 "schedulable_placements": [
6430 {
6431 "role": "explorer",
6432 "pool": "gpu-pool",
6433 "registry_url": "https://control-plane:9562",
6434 "token_env": "WORKER_TOKEN",
6435 "ca_cert_file": "/etc/bamboo/worker.pem"
6436 },
6437 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6438 ]
6439 }"#;
6440 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6441 assert_eq!(cfg.schedulable_placements.len(), 2);
6442 let p0 = &cfg.schedulable_placements[0];
6443 assert_eq!(p0.role, "explorer");
6444 assert_eq!(p0.pool, "gpu-pool");
6445 assert_eq!(p0.registry_url, "https://control-plane:9562");
6446 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6447 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6448 let p1 = &cfg.schedulable_placements[1];
6450 assert_eq!(p1.role, "writer");
6451 assert_eq!(p1.pool, "cpu-pool");
6452 assert!(p1.token_env.is_none());
6453 assert!(p1.ca_cert_file.is_none());
6454
6455 let back = serde_json::to_string(&cfg).unwrap();
6456 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6457 assert_eq!(cfg, reparsed, "round-trip is stable");
6458 assert!(!back.contains("\"token_env\":null"));
6459 assert!(!back.contains("\"ca_cert_file\":null"));
6460 }
6461
6462 #[test]
6463 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6464 let json = r#"{ "max_concurrent": 4 }"#;
6468 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6469 assert!(cfg.mcp_role_allowlist.is_empty());
6470 let back = serde_json::to_string(&cfg).unwrap();
6472 assert!(
6473 !back.contains("mcp_role_allowlist"),
6474 "empty vec is skipped: {back}"
6475 );
6476 }
6477
6478 #[test]
6479 fn mcp_role_allowlist_entry_round_trips() {
6480 let json = r#"{
6481 "mcp_role_allowlist": [
6482 { "role": "researcher", "tools": ["fetch_url"] },
6483 { "role": "sandboxed", "tools": [] }
6484 ]
6485 }"#;
6486 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6487 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6488 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6489 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6490 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6493 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6494
6495 let back = serde_json::to_string(&cfg).unwrap();
6496 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6497 assert_eq!(cfg, reparsed, "round-trip is stable");
6498 }
6499
6500 #[test]
6501 fn server_config_without_tls_field_deserializes_back_compat() {
6502 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6505 "port": 9562,
6506 "bind": "127.0.0.1"
6507 }))
6508 .expect("legacy server config without tls should deserialize");
6509
6510 assert_eq!(server.tls, None);
6511 assert_eq!(server.port, 9562);
6512 assert_eq!(server.bind, "127.0.0.1");
6513 }
6514
6515 #[test]
6516 fn server_config_omits_tls_when_none() {
6517 let server = ServerConfig::default();
6520 let value = serde_json::to_value(&server).expect("server config should serialize");
6521 let obj = value
6522 .as_object()
6523 .expect("server config serializes to object");
6524 assert!(
6525 !obj.contains_key("tls"),
6526 "tls must be omitted when None, got: {value}"
6527 );
6528 }
6529
6530 #[test]
6531 fn server_config_with_tls_roundtrips() {
6532 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6533 "port": 9562,
6534 "bind": "0.0.0.0",
6535 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6536 }))
6537 .expect("server config with tls should deserialize");
6538
6539 let tls = server.tls.clone().expect("tls should be Some");
6540 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6541 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6542
6543 let value = serde_json::to_value(&server).expect("serialize");
6545 assert!(value.as_object().unwrap().contains_key("tls"));
6546 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6547 assert_eq!(back.tls, server.tls);
6548 }
6549
6550 #[test]
6551 fn access_control_without_devices_field_deserializes_back_compat() {
6552 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6555 "password_enabled": true,
6556 "password_hash": "deadbeef",
6557 "password_salt": "01020304",
6558 }))
6559 .expect("legacy access_control without devices should deserialize");
6560
6561 assert!(access.devices.is_empty());
6562 assert!(access.password_enabled);
6563 }
6564
6565 #[test]
6566 fn access_control_omits_devices_when_empty() {
6567 let access = AccessControlConfig {
6570 password_enabled: true,
6571 repair_required: false,
6572 password_hash: Some("deadbeef".to_string()),
6573 password_salt: Some("01020304".to_string()),
6574 password_credential_ref: None,
6575 password_configured: false,
6576 updated_at: None,
6577 devices: Vec::new(),
6578 };
6579 let value = serde_json::to_value(&access).expect("serialize");
6580 let obj = value.as_object().expect("object");
6581 assert!(
6582 !obj.contains_key("devices"),
6583 "devices must be omitted when empty, got: {value}"
6584 );
6585 }
6586
6587 #[test]
6588 fn access_control_with_devices_roundtrips() {
6589 let device = DeviceCredential {
6590 device_id: "bamboo_0123456789ab".to_string(),
6591 label: "iPhone 15".to_string(),
6592 token_hash: "abcd".to_string(),
6593 token_salt: "ef01".to_string(),
6594 token_credential_ref: None,
6595 token_configured: false,
6596 created_at: "2026-06-23T00:00:00Z".to_string(),
6597 last_used_at: None,
6598 revoked: false,
6599 };
6600 let access = AccessControlConfig {
6601 password_enabled: true,
6602 repair_required: false,
6603 password_hash: Some("deadbeef".to_string()),
6604 password_salt: Some("01020304".to_string()),
6605 password_credential_ref: None,
6606 password_configured: false,
6607 updated_at: None,
6608 devices: vec![device.clone()],
6609 };
6610 let value = serde_json::to_value(&access).expect("serialize");
6611 assert!(value.as_object().unwrap().contains_key("devices"));
6612 assert!(value["devices"][0].get("token_hash").is_none());
6613 assert!(value["devices"][0].get("token_salt").is_none());
6614 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6615 assert_eq!(back.devices[0].device_id, device.device_id);
6616 assert!(back.devices[0].token_hash.is_empty());
6617 assert!(back.devices[0].token_salt.is_empty());
6618 }
6619
6620 #[test]
6621 fn reasoning_effort_for_key_resolves_instance_id() {
6622 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6626 "provider_type": "copilot",
6627 "reasoning_effort": "high",
6628 }))
6629 .expect("instance config should deserialize");
6630
6631 let mut config = Config::create_default();
6632 config
6633 .provider_instances
6634 .insert("copilot-work".to_string(), instance);
6635
6636 assert_eq!(
6637 config.reasoning_effort_for_key("copilot-work"),
6638 Some(ReasoningEffort::High),
6639 );
6640 }
6641
6642 #[test]
6643 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6644 let mut config = Config::create_default();
6646 config.providers.bodhi = Some(
6647 serde_json::from_value(serde_json::json!({
6648 "reasoning_effort": "xhigh",
6649 }))
6650 .expect("bodhi config should deserialize"),
6651 );
6652
6653 assert_eq!(
6654 config.reasoning_effort_for_key("bodhi"),
6655 Some(ReasoningEffort::Xhigh),
6656 );
6657 }
6658
6659 #[test]
6660 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6661 let mut config = Config::create_default();
6662 config.providers.openai = Some(
6663 serde_json::from_value(serde_json::json!({
6664 "api_key": "sk-test",
6665 "reasoning_effort": "low",
6666 }))
6667 .expect("openai config should deserialize"),
6668 );
6669
6670 assert_eq!(
6671 config.reasoning_effort_for_key("openai"),
6672 Some(ReasoningEffort::Low),
6673 );
6674 }
6675
6676 #[test]
6677 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6678 let config = Config::create_default();
6679 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6680 assert_eq!(config.reasoning_effort_for_key(" "), None);
6681 }
6682
6683 struct TempHome {
6684 path: PathBuf,
6685 }
6686
6687 impl TempHome {
6688 fn new() -> Self {
6689 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6701 std::sync::atomic::AtomicU64::new(0);
6702 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6703 let nanos = SystemTime::now()
6704 .duration_since(UNIX_EPOCH)
6705 .expect("clock should be after unix epoch")
6706 .as_nanos();
6707 let path = std::env::temp_dir().join(format!(
6708 "chat-core-config-test-{}-{}-{}",
6709 std::process::id(),
6710 nanos,
6711 unique
6712 ));
6713 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6714 Self { path }
6715 }
6716
6717 fn set_config_json(&self, content: &str) {
6718 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6721 std::fs::write(self.path.join("config.json"), content)
6722 .expect("failed to write config.json");
6723 }
6724 }
6725
6726 impl Drop for TempHome {
6727 fn drop(&mut self) {
6728 let _ = std::fs::remove_dir_all(&self.path);
6729 }
6730 }
6731
6732 fn env_lock() -> &'static Mutex<()> {
6736 crate::test_support::env_cache_lock()
6737 }
6738
6739 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6741 env_lock().lock().unwrap_or_else(|poisoned| {
6742 poisoned.into_inner()
6744 })
6745 }
6746
6747 #[test]
6748 fn parse_bool_env_true_values() {
6749 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6750 assert!(parse_bool_env(value), "value {value:?} should be true");
6751 }
6752 }
6753
6754 #[test]
6755 fn parse_bool_env_false_values() {
6756 for value in ["0", "false", "no", "off", "", " "] {
6757 assert!(!parse_bool_env(value), "value {value:?} should be false");
6758 }
6759 }
6760
6761 #[test]
6762 fn config_new_ignores_http_proxy_env_vars() {
6763 let _lock = env_lock_acquire();
6764 let temp_home = TempHome::new();
6765 temp_home.set_config_json(
6766 r#"{
6767 "http_proxy": "",
6768 "https_proxy": ""
6769}"#,
6770 );
6771
6772 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6773 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6774
6775 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6776
6777 assert!(
6778 config.http_proxy.is_empty(),
6779 "config should ignore HTTP_PROXY env var"
6780 );
6781 assert!(
6782 config.https_proxy.is_empty(),
6783 "config should ignore HTTPS_PROXY env var"
6784 );
6785 }
6786
6787 #[test]
6788 fn config_new_loads_config_when_proxy_fields_omitted() {
6789 let _lock = env_lock_acquire();
6790 let temp_home = TempHome::new();
6791 temp_home.set_config_json(
6792 r#"{
6793 "provider": "openai",
6794 "providers": {
6795 "openai": {
6796 "api_key": "sk-test",
6797 "model": "gpt-4o"
6798 }
6799 }
6800}"#,
6801 );
6802
6803 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6804 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6805
6806 let config = Config::from_data_dir(Some(temp_home.path.clone()));
6807
6808 assert_eq!(
6809 config
6810 .providers
6811 .openai
6812 .as_ref()
6813 .and_then(|c| c.model.as_deref()),
6814 Some("gpt-4o"),
6815 "config should load provider model from config file even when proxy fields are omitted"
6816 );
6817 assert!(config.http_proxy.is_empty());
6818 assert!(config.https_proxy.is_empty());
6819 }
6820
6821 #[test]
6822 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6823 let _lock = crate::test_support::env_cache_lock_acquire();
6824 let mut config = Config::default();
6825 config.env_vars.extend([
6826 EnvVarEntry {
6827 name: "SECRET_TOKEN".to_string(),
6828 value: "top-secret".to_string(),
6829 secret: true,
6830 value_encrypted: None,
6831 credential_ref: None,
6832 configured: true,
6833 description: Some("Service token".to_string()),
6834 },
6835 EnvVarEntry {
6836 name: "API_BASE".to_string(),
6837 value: "https://internal.example".to_string(),
6838 secret: false,
6839 value_encrypted: None,
6840 credential_ref: None,
6841 configured: true,
6842 description: Some("Internal API base".to_string()),
6843 },
6844 ]);
6845
6846 config.publish_env_vars();
6847
6848 let injected = Config::current_env_vars();
6849 assert_eq!(
6850 injected.get("SECRET_TOKEN").map(String::as_str),
6851 Some("top-secret")
6852 );
6853 assert_eq!(
6854 injected.get("API_BASE").map(String::as_str),
6855 Some("https://internal.example")
6856 );
6857
6858 let prompt_safe = Config::current_prompt_safe_env_vars();
6859 assert_eq!(prompt_safe.len(), 2);
6860 assert!(prompt_safe.iter().any(|entry| {
6861 entry.name == "SECRET_TOKEN"
6862 && entry.secret
6863 && entry.description.as_deref() == Some("Service token")
6864 }));
6865 assert!(prompt_safe.iter().any(|entry| {
6866 entry.name == "API_BASE"
6867 && !entry.secret
6868 && entry.description.as_deref() == Some("Internal API base")
6869 }));
6870 assert!(!prompt_safe
6871 .iter()
6872 .any(|entry| entry.name.contains("top-secret")));
6873 assert!(!prompt_safe.iter().any(|entry| {
6874 entry
6875 .description
6876 .as_deref()
6877 .is_some_and(|value| value.contains("https://internal.example"))
6878 }));
6879 }
6880
6881 #[test]
6882 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6883 let _lock = crate::test_support::env_cache_lock_acquire();
6884
6885 let mut live = Config::default();
6887 live.env_vars.extend([EnvVarEntry {
6888 name: "BAMBOO_CACHE_OWNER_40".to_string(),
6889 value: "live".to_string(),
6890 secret: false,
6891 value_encrypted: None,
6892 credential_ref: None,
6893 configured: true,
6894 description: None,
6895 }]);
6896 live.publish_env_vars();
6897 assert_eq!(
6898 Config::current_env_vars()
6899 .get("BAMBOO_CACHE_OWNER_40")
6900 .map(String::as_str),
6901 Some("live")
6902 );
6903
6904 let temp = TempHome::new();
6906 temp.set_config_json(
6907 &serde_json::json!({
6908 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6909 })
6910 .to_string(),
6911 );
6912
6913 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6916 assert_eq!(
6917 loaded
6918 .env_vars
6919 .iter()
6920 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6921 .map(|e| e.value.as_str()),
6922 Some("stale-disk"),
6923 "the returned Config holds the disk value"
6924 );
6925 assert_eq!(
6926 Config::current_env_vars()
6927 .get("BAMBOO_CACHE_OWNER_40")
6928 .map(String::as_str),
6929 Some("live"),
6930 "but the global cache is UNTOUCHED — no clobber (#40)"
6931 );
6932
6933 let _ = Config::from_data_dir(Some(temp.path.clone()));
6935 assert_eq!(
6936 Config::current_env_vars()
6937 .get("BAMBOO_CACHE_OWNER_40")
6938 .map(String::as_str),
6939 Some("stale-disk"),
6940 "the publishing loader clobbers the cache (contrast)"
6941 );
6942 }
6943
6944 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6945 std::fs::read_dir(dir)
6946 .unwrap()
6947 .filter_map(|e| e.ok())
6948 .any(|e| {
6949 e.file_name()
6950 .to_string_lossy()
6951 .contains("config.json.corrupted.")
6952 })
6953 }
6954
6955 #[test]
6956 fn corrupt_config_recovered_from_backup_and_quarantined() {
6957 let temp = TempHome::new();
6958 std::fs::write(
6960 temp.path.join("config.json.bak"),
6961 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6962 )
6963 .unwrap();
6964 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6966
6967 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6968 assert_eq!(
6969 config.http_proxy, "http://from-backup",
6970 "recovered from config.json.bak instead of losing all config"
6971 );
6972 assert!(
6973 dir_has_quarantine_file(&temp.path),
6974 "corrupt config.json was quarantined (preserved), not discarded"
6975 );
6976 }
6977
6978 #[test]
6979 fn corrupt_config_without_backup_quarantines_then_defaults() {
6980 let temp = TempHome::new();
6981 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6982
6983 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6984 assert!(
6985 config.http_proxy.is_empty(),
6986 "no backup -> falls back to defaults"
6987 );
6988 assert!(
6989 dir_has_quarantine_file(&temp.path),
6990 "corrupt config.json is quarantined even when there's no backup"
6991 );
6992 }
6993
6994 #[test]
6995 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6996 let temp = TempHome::new();
6997 temp.set_config_json(
7002 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7003 );
7004
7005 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7006 assert_eq!(
7007 config.http_proxy, "http://salvaged",
7008 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7009 );
7010 assert!(
7011 config.env_vars.is_empty(),
7012 "the corrupt field reset to its default instead of failing the whole load"
7013 );
7014 assert!(
7015 dir_has_quarantine_file(&temp.path),
7016 "the corrupt config.json was still quarantined for inspection"
7017 );
7018 }
7019
7020 #[test]
7021 fn salvage_preferred_over_backup_for_most_recent_intent() {
7022 let temp = TempHome::new();
7023 std::fs::write(
7025 temp.path.join("config.json.bak"),
7026 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7027 )
7028 .unwrap();
7029 temp.set_config_json(
7031 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7032 );
7033
7034 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7035 assert_eq!(
7036 config.http_proxy, "http://new-salvaged",
7037 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7038 );
7039 }
7040
7041 #[test]
7042 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7043 let temp = TempHome::new();
7044 std::fs::write(
7046 temp.path.join("config.json.bak"),
7047 serde_json::json!({
7048 "http_proxy": "http://old-from-backup",
7049 "https_proxy": "https://kept-from-backup",
7050 })
7051 .to_string(),
7052 )
7053 .unwrap();
7054 temp.set_config_json(
7057 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7058 );
7059
7060 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7061 assert_eq!(
7063 config.http_proxy, "http://newer-edit",
7064 "the corrupt file's newer valid edit is applied"
7065 );
7066 assert_eq!(
7068 config.https_proxy, "https://kept-from-backup",
7069 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7070 );
7071 }
7072
7073 #[test]
7074 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7075 let temp = TempHome::new();
7076 std::fs::write(
7077 temp.path.join("config.json.bak"),
7078 serde_json::json!({
7079 "providers": {
7080 "anthropic": { "model": "claude-backup" }
7081 },
7082 "memory": {
7083 "auto_dream_enabled": true
7084 },
7085 "subagents": {
7086 "claude_code_model": "claude-code-backup"
7087 }
7088 })
7089 .to_string(),
7090 )
7091 .unwrap();
7092 assert!(!temp.path.join("providers.json").exists());
7093 assert!(!temp.path.join("memory.json").exists());
7094 assert!(!temp.path.join("subagents.json").exists());
7095
7096 let (salvaged, recovered_fields) = Config::salvage_partial(
7097 r#"{"providers":"schema-invalid-provider-section"}"#,
7098 &temp.path,
7099 )
7100 .expect("object-shaped corrupt config should be salvageable");
7101
7102 assert!(
7103 recovered_fields.is_empty(),
7104 "the schema-invalid provider field must not replace the backup baseline"
7105 );
7106 assert_eq!(
7107 salvaged
7108 .providers()
7109 .anthropic
7110 .as_ref()
7111 .and_then(|provider| provider.model.as_deref()),
7112 Some("claude-backup")
7113 );
7114 assert!(
7115 salvaged
7116 .memory()
7117 .as_ref()
7118 .expect("backup memory config survives")
7119 .auto_dream_enabled
7120 );
7121 assert_eq!(
7122 salvaged.subagents().claude_code_model.as_deref(),
7123 Some("claude-code-backup")
7124 );
7125 }
7126
7127 #[test]
7128 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7129 let temp = TempHome::new();
7130 std::fs::write(
7133 temp.path.join("config.json.bak"),
7134 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7135 )
7136 .unwrap();
7137 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7138
7139 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7140 assert_eq!(
7141 config.http_proxy, "http://from-backup",
7142 "garbage (non-object) config skips salvage and recovers from .bak"
7143 );
7144 }
7145
7146 #[test]
7147 fn quarantine_files_are_capped_to_newest_n() {
7148 let temp = TempHome::new();
7149 let config_path = temp.path.join("config.json");
7150 std::fs::write(&config_path, "{}").unwrap();
7151
7152 for _ in 0..(QUARANTINE_KEEP + 3) {
7155 quarantine_corrupt_config(&config_path);
7156 std::thread::sleep(std::time::Duration::from_millis(3));
7157 }
7158
7159 let count = std::fs::read_dir(&temp.path)
7160 .unwrap()
7161 .filter_map(|e| e.ok())
7162 .filter(|e| {
7163 e.file_name()
7164 .to_string_lossy()
7165 .starts_with("config.json.corrupted.")
7166 })
7167 .count();
7168 assert_eq!(
7169 count, QUARANTINE_KEEP,
7170 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7171 );
7172 }
7173
7174 #[test]
7175 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7176 let temp = TempHome::new();
7177 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7179 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7180 std::fs::write(
7181 temp.path.join("config.json.bak.1"),
7182 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7183 )
7184 .unwrap();
7185
7186 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7187 assert_eq!(
7188 config.http_proxy, "http://from-gen-1",
7189 "recovered from .bak.1 when both config.json and .bak are corrupt"
7190 );
7191 }
7192
7193 #[test]
7194 fn save_rotates_backup_generations() {
7195 let temp = TempHome::new();
7196 let path = temp.path.join("config.json");
7197 std::fs::write(
7199 &path,
7200 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7201 )
7202 .unwrap();
7203
7204 let mut cfg = Config::create_default();
7205 cfg.http_proxy = "http://proxy-v2".to_string();
7207 cfg.save_to_dir(temp.path.clone()).unwrap();
7208 cfg.http_proxy = "http://proxy-v3".to_string();
7210 cfg.save_to_dir(temp.path.clone()).unwrap();
7211
7212 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7213 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7214 assert!(
7215 bak.contains("proxy-v2"),
7216 ".bak holds the previous generation (v2)"
7217 );
7218 assert!(
7219 bak1.contains("proxy-v1"),
7220 ".bak.1 holds the older rotated generation (v1)"
7221 );
7222 }
7223
7224 #[test]
7225 fn save_backs_up_existing_config() {
7226 let temp = TempHome::new();
7227 std::fs::write(
7229 temp.path.join("config.json"),
7230 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7231 )
7232 .unwrap();
7233
7234 let mut config = Config::create_default();
7235 config.http_proxy = "http://new".to_string();
7236 config
7237 .save_to_dir(temp.path.clone())
7238 .expect("save succeeds");
7239
7240 let backup =
7241 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7242 assert!(
7243 backup.contains("http://old"),
7244 "config.json.bak holds the PREVIOUS config (last-known-good)"
7245 );
7246 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7247 assert!(
7248 current.contains("http://new"),
7249 "config.json holds the new config"
7250 );
7251 }
7252
7253 #[test]
7254 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7255 let temp = TempHome::new();
7256 std::fs::write(
7258 temp.path.join("config.json.bak"),
7259 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7260 )
7261 .unwrap();
7262 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7265
7266 let mut config = Config::create_default();
7267 config.http_proxy = "http://new".to_string();
7268 config
7269 .save_to_dir(temp.path.clone())
7270 .expect("save succeeds");
7271
7272 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7274 assert!(
7275 backup.contains("http://good-bak"),
7276 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7277 );
7278 }
7279
7280 #[test]
7283 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7284 let temp = TempHome::new();
7285 std::fs::write(
7286 temp.path.join("config.json.bak"),
7287 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7288 )
7289 .unwrap();
7290 let corrupt_bytes = "{ not valid json ";
7291 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7292
7293 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7294 let status = config
7295 .recovery_status()
7296 .expect("a corrupt load must set a pending recovery status");
7297 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7298 assert_eq!(
7299 status.source,
7300 ConfigRecoverySource::Backup { generation: 0 },
7301 "recovered from generation-0 (.bak)"
7302 );
7303 let quarantine_path = status
7304 .quarantine_path
7305 .as_ref()
7306 .expect("quarantine copy should have succeeded");
7307 assert_eq!(
7308 std::fs::read_to_string(quarantine_path).unwrap(),
7309 corrupt_bytes,
7310 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7311 );
7312 assert_eq!(
7313 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7314 corrupt_bytes,
7315 "the original config.json itself is untouched by the load (only copied, not moved)"
7316 );
7317 }
7318
7319 #[test]
7320 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7321 let temp = TempHome::new();
7322 temp.set_config_json(
7323 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7324 );
7325
7326 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7327 let status = config.recovery_status().expect("pending recovery");
7328 assert!(!status.confirmed);
7329 match &status.source {
7330 ConfigRecoverySource::Salvaged { fields } => {
7331 assert!(
7332 fields.iter().any(|f| f == "http_proxy"),
7333 "salvaged fields should list the recovered key: {fields:?}"
7334 );
7335 }
7336 other => panic!("expected Salvaged source, got {other:?}"),
7337 }
7338 }
7339
7340 #[test]
7341 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7342 let temp = TempHome::new();
7343 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7345
7346 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7347 let status = config.recovery_status().expect("pending recovery");
7348 assert!(!status.confirmed);
7349 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7350 }
7351
7352 #[test]
7353 fn clean_load_never_sets_recovery_status() {
7354 let temp = TempHome::new();
7355 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7356
7357 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7358 assert!(
7359 config.recovery_status().is_none(),
7360 "a config.json that parses cleanly must never carry a pending recovery status"
7361 );
7362 }
7363
7364 #[test]
7365 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7366 let temp = TempHome::new();
7367 let corrupt_bytes = "}}} broken";
7368 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7369
7370 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7371 assert!(config.recovery_status().is_some());
7372
7373 let err = config
7374 .save_to_dir(temp.path.clone())
7375 .expect_err("save must refuse while recovery is unconfirmed");
7376 assert!(
7377 err.to_string().contains("recovered from corruption")
7378 || err.to_string().contains("confirm"),
7379 "error should explain the refused overwrite: {err}"
7380 );
7381
7382 assert_eq!(
7385 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7386 corrupt_bytes,
7387 "a refused save must leave the corrupt original untouched"
7388 );
7389 }
7390
7391 #[test]
7392 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7393 let temp = TempHome::new();
7394 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7396 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7397
7398 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7399 let status = config.recovery_status().expect("pending recovery");
7400 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7401 assert_eq!(
7402 std::fs::read_to_string(quarantine_path).unwrap(),
7403 truncated,
7404 "truncated original preserved byte for byte in quarantine"
7405 );
7406
7407 let err = config.save_to_dir(temp.path.clone());
7408 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7409 assert_eq!(
7410 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7411 truncated,
7412 "the half-written original stays exactly as it was after a refused save"
7413 );
7414 }
7415
7416 #[test]
7417 fn confirm_recovery_allows_the_next_save() {
7418 let temp = TempHome::new();
7419 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7420
7421 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7422 assert!(config.recovery_status().is_some());
7423
7424 config.confirm_recovery();
7425 assert!(
7426 config.recovery_status().is_some_and(|s| s.confirmed),
7427 "confirm_recovery flips the flag but keeps the status around"
7428 );
7429
7430 config
7431 .save_to_dir(temp.path.clone())
7432 .expect("save must succeed once the recovery is confirmed");
7433 }
7434
7435 #[test]
7436 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7437 let temp = TempHome::new();
7438 std::fs::write(
7439 temp.path.join("config.json"),
7440 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7441 )
7442 .unwrap();
7443
7444 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7445 assert!(config.recovery_status().is_some());
7446 let quarantine_path = config
7447 .recovery_status()
7448 .unwrap()
7449 .quarantine_path
7450 .clone()
7451 .unwrap();
7452
7453 config
7454 .confirm_recovery_and_save_to_dir(temp.path.clone())
7455 .expect("confirm+save should succeed");
7456
7457 assert!(
7458 config.recovery_status().is_none(),
7459 "the pending flag is cleared once the recovery is confirmed and persisted"
7460 );
7461 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7462 assert!(
7463 on_disk.contains("http://recovered"),
7464 "config.json now holds the recovered (salvaged) state"
7465 );
7466 assert!(
7469 quarantine_path.exists(),
7470 "the quarantined original survives confirmation — it's never deleted"
7471 );
7472 }
7473
7474 #[test]
7475 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7476 let temp = TempHome::new();
7477 let mut config = Config::create_default();
7478 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7479 assert!(
7480 err.is_err(),
7481 "confirming a recovery that was never pending must error, not silently succeed"
7482 );
7483 }
7484
7485 fn connect_platform_with_encrypted(
7488 platform_type: &str,
7489 token_encrypted: &str,
7490 ) -> ConnectPlatformConfig {
7491 ConnectPlatformConfig {
7492 id: None,
7493 project_id: None,
7494 platform_type: platform_type.to_string(),
7495 token: None,
7496 token_encrypted: Some(token_encrypted.to_string()),
7497 token_credential_ref: None,
7498 token_configured: false,
7499 app_id: None,
7500 app_secret: None,
7501 app_secret_encrypted: None,
7502 app_secret_credential_ref: None,
7503 app_secret_configured: false,
7504 domain: None,
7505 allow_from: vec!["user-1".to_string()],
7506 admin_from: Vec::new(),
7507 }
7508 }
7509
7510 fn connect_json_path(temp: &TempHome) -> PathBuf {
7511 temp.path.join("connect.json")
7512 }
7513
7514 #[test]
7515 fn save_splits_connect_into_sibling_connect_json() {
7516 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7517 let temp = TempHome::new();
7518
7519 let mut config = Config::create_default();
7520 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7521 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7522
7523 config
7524 .save_to_dir(temp.path.clone())
7525 .expect("save succeeds");
7526
7527 let config_json: serde_json::Value =
7528 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7529 .unwrap();
7530 assert!(
7531 config_json.get("connect").is_none(),
7532 "config.json must not carry the `connect` key after a save"
7533 );
7534
7535 let connect_json: serde_json::Value =
7536 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7537 .unwrap();
7538 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7539 assert!(
7540 connect_json["platforms"][0]["token_encrypted"]
7541 .as_str()
7542 .is_some_and(|v| !v.is_empty()),
7543 "the token is persisted in its encrypted form in connect.json"
7544 );
7545 assert!(
7546 connect_json["platforms"][0].get("token").is_none(),
7547 "the plaintext token is never persisted (skip_serializing)"
7548 );
7549 }
7550
7551 #[test]
7554 fn save_assigns_a_missing_connect_platform_id() {
7555 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7556 let temp = TempHome::new();
7557
7558 let mut config = Config::create_default();
7559 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7560 assert!(
7561 config.connect.platforms[0].id.is_none(),
7562 "precondition: the entry starts without an id"
7563 );
7564
7565 config
7566 .save_to_dir(temp.path.clone())
7567 .expect("save succeeds");
7568
7569 let connect_json: serde_json::Value =
7570 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7571 .unwrap();
7572 let persisted_id = connect_json["platforms"][0]["id"]
7573 .as_str()
7574 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7575 assert!(!persisted_id.is_empty());
7576
7577 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7578 assert_eq!(
7579 reloaded.connect.platforms[0].id.as_deref(),
7580 Some(persisted_id),
7581 "the assigned id round-trips through a reload"
7582 );
7583 }
7584
7585 #[test]
7586 fn save_never_reassigns_an_existing_connect_platform_id() {
7587 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7588 let temp = TempHome::new();
7589
7590 let mut config = Config::create_default();
7591 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7592 platform.id = Some("stable-id-123".to_string());
7593 config.connect.platforms = vec![platform];
7594
7595 config
7596 .save_to_dir(temp.path.clone())
7597 .expect("first save succeeds");
7598 config
7600 .save_to_dir(temp.path.clone())
7601 .expect("second save succeeds");
7602
7603 let connect_json: serde_json::Value =
7604 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7605 .unwrap();
7606 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7607 }
7608
7609 #[test]
7610 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7611 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7612 let temp = TempHome::new();
7613
7614 let mut config = Config::create_default();
7615 config.connect.platforms = vec![
7616 connect_platform_with_encrypted("telegram", "cipher-a"),
7617 connect_platform_with_encrypted("telegram", "cipher-b"),
7618 ];
7619
7620 config
7621 .save_to_dir(temp.path.clone())
7622 .expect("save succeeds");
7623
7624 let connect_json: serde_json::Value =
7625 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7626 .unwrap();
7627 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7628 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7629 assert_ne!(
7630 id_a, id_b,
7631 "two entries sharing platform_type must still get distinct ids"
7632 );
7633 }
7634
7635 #[test]
7636 fn load_never_assigns_or_persists_an_id_by_itself() {
7637 let temp = TempHome::new();
7638 std::fs::write(
7639 connect_json_path(&temp),
7640 serde_json::json!({
7641 "platforms": [
7642 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7643 ]
7644 })
7645 .to_string(),
7646 )
7647 .unwrap();
7648 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7649
7650 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7651
7652 assert!(
7653 config.connect.platforms[0].id.is_none(),
7654 "load alone must not backfill an id in memory"
7655 );
7656 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7657 assert_eq!(
7658 connect_json_before, connect_json_after,
7659 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7660 );
7661 }
7662
7663 #[test]
7664 fn load_merges_connect_json_into_config() {
7665 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7666 let temp = TempHome::new();
7667 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7668 std::fs::write(
7669 connect_json_path(&temp),
7670 serde_json::json!({
7671 "platforms": [
7672 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7673 ]
7674 })
7675 .to_string(),
7676 )
7677 .unwrap();
7678
7679 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7680 assert_eq!(config.connect.platforms.len(), 1);
7681 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7682 assert_eq!(
7683 config.connect.platforms[0].token.as_deref(),
7684 Some("connect-secret")
7685 );
7686 assert!(config.connect.platforms[0].token_encrypted.is_none());
7687 }
7688
7689 #[test]
7690 fn load_without_connect_json_yields_empty_inert_connect_config() {
7691 let temp = TempHome::new();
7692 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7693
7694 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7695 assert!(
7696 config.connect.platforms.is_empty(),
7697 "no connect.json and no legacy key -> empty/inert connect config"
7698 );
7699 assert!(
7700 !connect_json_path(&temp).exists(),
7701 "load must not create connect.json when there is nothing to migrate"
7702 );
7703 }
7704
7705 #[test]
7706 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7707 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7708 let temp = TempHome::new();
7709 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7710 let legacy_cipher_for_assert = legacy_cipher.clone();
7711 temp.set_config_json(
7713 &serde_json::json!({
7714 "http_proxy": "http://keep-me",
7715 "connect": {
7716 "platforms": [
7717 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7718 ]
7719 }
7720 })
7721 .to_string(),
7722 );
7723
7724 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7725
7726 assert_eq!(config.connect.platforms.len(), 1);
7728 assert_eq!(
7729 config.connect.platforms[0].token.as_deref(),
7730 Some("legacy-connect-secret")
7731 );
7732 assert_eq!(config.http_proxy, "http://keep-me");
7734
7735 assert!(
7737 connect_json_path(&temp).exists(),
7738 "migration proactively creates connect.json"
7739 );
7740 let connect_json: serde_json::Value =
7741 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7742 .unwrap();
7743 assert_eq!(
7744 connect_json["platforms"][0]["token_encrypted"],
7745 legacy_cipher_for_assert
7746 );
7747
7748 let config_json: serde_json::Value =
7750 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7751 .unwrap();
7752 assert!(
7753 config_json.get("connect").is_none(),
7754 "config.json is rewritten without the legacy `connect` key"
7755 );
7756 }
7757
7758 #[test]
7766 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7767 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7768 let temp = TempHome::new();
7769
7770 let original_api_key_encrypted =
7771 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7772 temp.set_config_json(
7773 &serde_json::json!({
7774 "providers": {
7775 "openai": {
7776 "api_key_encrypted": original_api_key_encrypted,
7777 }
7778 },
7779 "connect": {
7780 "platforms": [
7781 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7782 ]
7783 }
7784 })
7785 .to_string(),
7786 );
7787
7788 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7789 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7790
7791 assert!(
7794 !temp.path.join("config.json.bak").exists(),
7795 "a read-only load migrating a legacy `connect` key must not rotate \
7796 config.json backups"
7797 );
7798
7799 let config_json: serde_json::Value =
7804 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7805 .unwrap();
7806 assert_eq!(
7807 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7808 "an unrelated secret's ciphertext must not be touched by the connect \
7809 migration's narrow write"
7810 );
7811 assert!(
7812 config_json.get("connect").is_none(),
7813 "config.json is still rewritten without the legacy `connect` key"
7814 );
7815 }
7816
7817 #[test]
7818 fn both_files_present_connect_json_wins() {
7819 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7820 let temp = TempHome::new();
7821 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7822 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7823 temp.set_config_json(
7824 &serde_json::json!({
7825 "connect": {
7826 "platforms": [
7827 { "type": "telegram", "token_encrypted": stale }
7828 ]
7829 }
7830 })
7831 .to_string(),
7832 );
7833 std::fs::write(
7834 connect_json_path(&temp),
7835 serde_json::json!({
7836 "platforms": [
7837 { "type": "telegram", "token_encrypted": authoritative }
7838 ]
7839 })
7840 .to_string(),
7841 )
7842 .unwrap();
7843
7844 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7845 assert_eq!(
7846 config.connect.platforms[0].token.as_deref(),
7847 Some("authoritative-secret"),
7848 "connect.json wins over a stale legacy config.json key"
7849 );
7850 }
7851
7852 #[test]
7857 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7858 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7859 let temp = TempHome::new();
7860 let stale = crate::encryption::encrypt("stale-secret").unwrap();
7861 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7862 temp.set_config_json(
7863 &serde_json::json!({
7864 "http_proxy": "http://keep-me",
7865 "connect": {
7866 "platforms": [
7867 { "type": "telegram", "token_encrypted": stale }
7868 ]
7869 }
7870 })
7871 .to_string(),
7872 );
7873 std::fs::write(
7874 connect_json_path(&temp),
7875 serde_json::json!({
7876 "platforms": [
7877 { "type": "telegram", "token_encrypted": authoritative }
7878 ]
7879 })
7880 .to_string(),
7881 )
7882 .unwrap();
7883
7884 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7885 assert_eq!(
7886 config.connect.platforms[0].token.as_deref(),
7887 Some("authoritative-secret")
7888 );
7889 assert_eq!(config.http_proxy, "http://keep-me");
7891
7892 let config_json: serde_json::Value =
7893 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7894 .unwrap();
7895 assert!(
7896 config_json.get("connect").is_none(),
7897 "the stale legacy `connect` key must be stripped from config.json \
7898 immediately on load, not left for the next natural save"
7899 );
7900 }
7901
7902 #[test]
7910 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7911 let temp = TempHome::new();
7912 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7913 std::fs::write(
7914 temp.path.join("config.json.bak"),
7915 serde_json::json!({
7916 "http_proxy": "http://old",
7917 "connect": {
7918 "platforms": [
7919 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7920 ]
7921 }
7922 })
7923 .to_string(),
7924 )
7925 .unwrap();
7926
7927 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7928 assert!(config.connect.platforms.is_empty());
7931
7932 let bak: serde_json::Value = serde_json::from_str(
7933 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7934 )
7935 .unwrap();
7936 assert!(
7937 bak.get("connect").is_none(),
7938 "the legacy `connect` key must be stripped from the tainted .bak generation"
7939 );
7940 assert_eq!(
7941 bak["http_proxy"], "http://old",
7942 "every other key in the .bak generation survives the scrub byte-for-byte in content"
7943 );
7944 }
7945
7946 #[test]
7951 fn scrub_reaches_all_rotated_generations() {
7952 let temp = TempHome::new();
7953 temp.set_config_json(&serde_json::json!({}).to_string());
7954 for (gen_suffix, cipher) in [
7955 ("config.json.bak", "cipher-gen0"),
7956 ("config.json.bak.1", "cipher-gen1"),
7957 ("config.json.bak.2", "cipher-gen2"),
7958 ] {
7959 std::fs::write(
7960 temp.path.join(gen_suffix),
7961 serde_json::json!({
7962 "connect": {
7963 "platforms": [
7964 { "type": "telegram", "token_encrypted": cipher }
7965 ]
7966 }
7967 })
7968 .to_string(),
7969 )
7970 .unwrap();
7971 }
7972
7973 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7974
7975 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7976 let value: serde_json::Value =
7977 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7978 .unwrap();
7979 assert!(
7980 value.get("connect").is_none(),
7981 "{gen_suffix} must have its legacy `connect` key stripped"
7982 );
7983 }
7984 }
7985
7986 #[test]
7994 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7995 let temp = TempHome::new();
7996 temp.set_config_json(&serde_json::json!({}).to_string());
7997 let bak_path = temp.path.join("config.json.bak");
7998 std::fs::write(
7999 &bak_path,
8000 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8001 )
8002 .unwrap();
8003
8004 let before_bytes = std::fs::read(&bak_path).unwrap();
8005 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8006
8007 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8011
8012 let after_bytes = std::fs::read(&bak_path).unwrap();
8013 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8014 assert_eq!(
8015 before_bytes, after_bytes,
8016 "a .bak generation without a legacy `connect` key must not be rewritten at all"
8017 );
8018 assert_eq!(
8019 before_mtime, after_mtime,
8020 "no write means no mtime change either"
8021 );
8022 }
8023
8024 #[test]
8029 fn scrub_skips_unparsable_backup_without_deleting_it() {
8030 let temp = TempHome::new();
8031 temp.set_config_json(&serde_json::json!({}).to_string());
8032 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8033
8034 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8035
8036 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8037 assert_eq!(
8038 content, "{ not valid json",
8039 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8040 );
8041 }
8042
8043 #[test]
8047 fn scrub_tolerates_missing_generations() {
8048 let temp = TempHome::new();
8049 temp.set_config_json(&serde_json::json!({}).to_string());
8050 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8052 assert!(config.connect.platforms.is_empty());
8053 assert!(!temp.path.join("config.json.bak").exists());
8054 }
8055
8056 #[test]
8059 fn scrub_does_not_break_backup_rotation() {
8060 let temp = TempHome::new();
8061 std::fs::write(
8062 temp.path.join("config.json"),
8063 serde_json::json!({
8064 "http_proxy": "http://proxy-v1",
8065 "connect": {
8066 "platforms": [
8067 { "type": "telegram", "token_encrypted": "legacy-cipher" }
8068 ]
8069 }
8070 })
8071 .to_string(),
8072 )
8073 .unwrap();
8074 std::fs::write(
8075 temp.path.join("config.json.bak"),
8076 serde_json::json!({
8077 "http_proxy": "http://proxy-v0",
8078 "connect": {
8079 "platforms": [
8080 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8081 ]
8082 }
8083 })
8084 .to_string(),
8085 )
8086 .unwrap();
8087
8088 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8090 let bak: serde_json::Value = serde_json::from_str(
8091 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8092 )
8093 .unwrap();
8094 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8095
8096 config.http_proxy = "http://proxy-v2".to_string();
8099 config.save_to_dir(temp.path.clone()).unwrap();
8100
8101 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8102 assert!(
8103 new_bak.contains("proxy-v1"),
8104 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8105 );
8106 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8107 assert!(
8108 new_bak1.contains("proxy-v0"),
8109 ".bak.1 holds the scrubbed older generation after rotation"
8110 );
8111 assert!(
8112 !new_bak1.contains("legacy-bak-cipher"),
8113 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8114 stripped secret"
8115 );
8116 }
8117
8118 #[test]
8119 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8120 let temp = TempHome::new();
8121 temp.set_config_json("{}");
8122 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8123
8124 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8125 assert!(
8126 config.connect.platforms.is_empty(),
8127 "corrupt connect.json fails SAFE to an empty/inert connect config"
8128 );
8129
8130 let backup = connect_json_path(&temp).with_extension("json.bak");
8131 assert!(
8132 backup.exists(),
8133 "the corrupt connect.json is quarantined to connect.json.bak"
8134 );
8135 assert!(
8136 std::fs::read_to_string(backup)
8137 .unwrap()
8138 .contains("not valid json"),
8139 "the quarantined copy holds the bad content"
8140 );
8141 assert!(
8146 !connect_json_path(&temp).exists(),
8147 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8148 connect.json should remain after quarantine"
8149 );
8150 }
8151
8152 #[test]
8153 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8154 let temp = TempHome::new();
8155 temp.set_config_json(
8158 &serde_json::json!({
8159 "connect": {
8160 "platforms": [
8161 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8162 ]
8163 }
8164 })
8165 .to_string(),
8166 );
8167 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8168
8169 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8170 assert!(
8171 config.connect.platforms.is_empty(),
8172 "corrupt connect.json must not fall back to the legacy config.json copy"
8173 );
8174 }
8175
8176 #[test]
8177 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8178 let temp = TempHome::new();
8179 let config = Config::create_default();
8180 assert!(config.connect.platforms.is_empty());
8181
8182 config
8183 .save_to_dir(temp.path.clone())
8184 .expect("save succeeds");
8185
8186 assert!(
8187 !connect_json_path(&temp).exists(),
8188 "an empty connect config with no pre-existing file must not create one"
8189 );
8190 }
8191
8192 #[test]
8193 fn connect_json_backed_up_before_overwrite() {
8194 let temp = TempHome::new();
8195 std::fs::write(
8196 connect_json_path(&temp),
8197 serde_json::json!({
8198 "platforms": [
8199 { "type": "telegram", "token_encrypted": "old-cipher" }
8200 ]
8201 })
8202 .to_string(),
8203 )
8204 .unwrap();
8205
8206 let mut config = Config::create_default();
8207 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8208 config
8209 .save_to_dir(temp.path.clone())
8210 .expect("save succeeds");
8211
8212 let backup = connect_json_path(&temp).with_extension("json.bak");
8213 assert!(
8214 std::fs::read_to_string(backup)
8215 .unwrap()
8216 .contains("old-cipher"),
8217 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8218 );
8219 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8220 assert!(current.contains("new-cipher"));
8221 }
8222
8223 #[test]
8226 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8227 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8228 let temp = TempHome::new();
8229
8230 let mut config = Config::create_default();
8231 config.connect.platforms = vec![ConnectPlatformConfig {
8232 id: None,
8233 project_id: None,
8234 platform_type: "feishu".to_string(),
8235 token: None,
8236 token_encrypted: None,
8237 token_credential_ref: None,
8238 token_configured: false,
8239 app_id: Some("cli_real_app_id".to_string()),
8240 app_secret: Some("plain-app-secret".to_string()),
8241 app_secret_encrypted: None,
8242 app_secret_credential_ref: None,
8243 app_secret_configured: false,
8244 domain: Some("lark".to_string()),
8245 allow_from: vec!["ou_1".to_string()],
8246 admin_from: Vec::new(),
8247 }];
8248
8249 config
8250 .save_to_dir(temp.path.clone())
8251 .expect("save succeeds");
8252
8253 let connect_json: serde_json::Value =
8254 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8255 .unwrap();
8256 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8257 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8258 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8259 assert!(
8260 connect_json["platforms"][0]["app_secret_encrypted"]
8261 .as_str()
8262 .is_some_and(|v| !v.is_empty()),
8263 "app_secret is persisted in its encrypted form in connect.json"
8264 );
8265 assert!(
8266 connect_json["platforms"][0].get("app_secret").is_none(),
8267 "the plaintext app_secret is never persisted (skip_serializing)"
8268 );
8269 }
8270
8271 #[test]
8272 fn load_hydrates_feishu_app_secret_from_encrypted() {
8273 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8274 let temp = TempHome::new();
8275
8276 let mut config = Config::create_default();
8277 config.connect.platforms = vec![ConnectPlatformConfig {
8278 id: None,
8279 project_id: None,
8280 platform_type: "feishu".to_string(),
8281 token: None,
8282 token_encrypted: None,
8283 token_credential_ref: None,
8284 token_configured: false,
8285 app_id: Some("cli_real_app_id".to_string()),
8286 app_secret: Some("plain-app-secret".to_string()),
8287 app_secret_encrypted: None,
8288 app_secret_credential_ref: None,
8289 app_secret_configured: false,
8290 domain: Some("lark".to_string()),
8291 allow_from: vec!["ou_1".to_string()],
8292 admin_from: Vec::new(),
8293 }];
8294 config
8295 .save_to_dir(temp.path.clone())
8296 .expect("save succeeds");
8297
8298 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8299 assert_eq!(reloaded.connect.platforms.len(), 1);
8300 assert_eq!(
8301 reloaded.connect.platforms[0].app_secret.as_deref(),
8302 Some("plain-app-secret"),
8303 "reload hydrates app_secret from app_secret_encrypted"
8304 );
8305 assert_eq!(
8306 reloaded.connect.platforms[0].app_id.as_deref(),
8307 Some("cli_real_app_id")
8308 );
8309 assert_eq!(
8310 reloaded.connect.platforms[0].domain.as_deref(),
8311 Some("lark")
8312 );
8313 }
8314
8315 #[test]
8316 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8317 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8318 let temp = TempHome::new();
8319 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8320 std::fs::write(
8321 connect_json_path(&temp),
8322 serde_json::json!({
8323 "platforms": [
8324 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8325 ]
8326 })
8327 .to_string(),
8328 )
8329 .unwrap();
8330
8331 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8332
8333 assert_eq!(config.connect.platforms.len(), 1);
8334 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8335 assert_eq!(
8336 config.connect.platforms[0].token.as_deref(),
8337 Some("legacy-telegram-secret")
8338 );
8339 assert_eq!(
8340 config.connect.platforms[0].app_id, None,
8341 "a legacy entry with no Feishu fields deserializes them as None"
8342 );
8343 assert_eq!(config.connect.platforms[0].app_secret, None);
8344 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8345 assert_eq!(config.connect.platforms[0].domain, None);
8346 }
8347
8348 #[test]
8349 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8350 let _lock = env_lock_acquire();
8351 let temp_home = TempHome::new();
8352 temp_home.set_config_json(
8353 r#"{
8354 "provider": "openai",
8355 "providers": {
8356 "openai": {
8357 "api_key": "sk-test",
8358 "model": "gpt-4o"
8359 }
8360 }
8361}"#,
8362 );
8363
8364 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8365 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8366
8367 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8368
8369 assert_eq!(
8370 config
8371 .providers
8372 .openai
8373 .as_ref()
8374 .and_then(|c| c.model.as_deref()),
8375 Some("gpt-4o")
8376 );
8377 assert!(
8378 config.http_proxy.is_empty(),
8379 "config should keep http_proxy empty when field is omitted"
8380 );
8381 assert!(
8382 config.https_proxy.is_empty(),
8383 "config should keep https_proxy empty when field is omitted"
8384 );
8385 }
8386
8387 #[test]
8388 fn get_memory_background_model_prefers_memory_specific_override() {
8389 let mut config = Config::default();
8390 config.features.provider_model_ref = false;
8391 config.provider = "openai".to_string();
8392 config.providers.openai = Some(OpenAIConfig {
8393 api_key: "test".to_string(),
8394 api_key_encrypted: None,
8395 credential_ref: None,
8396 base_url: None,
8397 model: Some("gpt-main".to_string()),
8398 fast_model: Some("gpt-fast".to_string()),
8399 vision_model: None,
8400 reasoning_effort: None,
8401 responses_only_models: vec![],
8402 request_overrides: None,
8403 extra: BTreeMap::new(),
8404 api_key_from_env: false,
8405 });
8406 config.memory.0 = Some(MemoryConfig {
8407 background_model: Some("memory-fast".to_string()),
8408 ..MemoryConfig::default()
8409 });
8410
8411 assert_eq!(
8412 config.get_memory_background_model().as_deref(),
8413 Some("memory-fast")
8414 );
8415 }
8416
8417 #[test]
8418 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8419 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8424 api_key: api_key.to_string(),
8425 api_key_encrypted: None,
8426 credential_ref: None,
8427 base_url: None,
8428 model: None,
8429 fast_model: None,
8430 vision_model: None,
8431 reasoning_effort: None,
8432 responses_only_models: vec![],
8433 request_overrides: None,
8434 extra: BTreeMap::new(),
8435 api_key_from_env: from_env,
8436 };
8437
8438 let mut current = Config::default();
8440 current.providers.openai = Some(openai("sk-env", true));
8441 let mut merged = Config::default();
8442 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8444 let got = merged.providers.openai.as_ref().unwrap();
8445 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8446 assert!(got.api_key_from_env, "env flag restored");
8447
8448 let mut merged = Config::default();
8450 merged.providers.openai = Some(openai("sk-explicit", false));
8451 merged.preserve_env_sourced_provider_keys(¤t);
8452 assert_eq!(
8453 merged.providers.openai.as_ref().unwrap().api_key,
8454 "sk-explicit",
8455 "explicit patch key must win"
8456 );
8457
8458 let mut current_plain = Config::default();
8460 current_plain.providers.openai = Some(openai("sk-plain", false));
8461 let mut merged = Config::default();
8462 merged.providers.openai = Some(openai("", false));
8463 merged.preserve_env_sourced_provider_keys(¤t_plain);
8464 assert!(
8465 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8466 "non-env key must not be restored by this path"
8467 );
8468 }
8469
8470 #[test]
8471 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8472 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8476 api_key: api_key.to_string(),
8477 api_key_encrypted: enc.map(str::to_string),
8478 credential_ref: None,
8479 base_url: None,
8480 model: None,
8481 fast_model: None,
8482 vision_model: None,
8483 reasoning_effort: None,
8484 responses_only_models: vec![],
8485 request_overrides: None,
8486 extra: BTreeMap::new(),
8487 api_key_from_env: false,
8488 };
8489
8490 let mut config = Config::default();
8492 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8493 config
8494 .refresh_provider_api_keys_encrypted()
8495 .expect("refresh");
8496 assert_eq!(
8497 config
8498 .providers
8499 .openai
8500 .as_ref()
8501 .unwrap()
8502 .api_key_encrypted
8503 .as_deref(),
8504 Some("preexisting-ciphertext"),
8505 "existing ciphertext must be preserved when plaintext is empty"
8506 );
8507
8508 let mut config = Config::default();
8510 config.providers.openai = Some(openai("", None));
8511 config
8512 .refresh_provider_api_keys_encrypted()
8513 .expect("refresh");
8514 assert!(
8515 config
8516 .providers
8517 .openai
8518 .as_ref()
8519 .unwrap()
8520 .api_key_encrypted
8521 .is_none(),
8522 "no key + no ciphertext should stay None"
8523 );
8524
8525 let mut config = Config::default();
8527 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8528 config
8529 .refresh_provider_api_keys_encrypted()
8530 .expect("refresh");
8531 let enc = config
8532 .providers
8533 .openai
8534 .as_ref()
8535 .unwrap()
8536 .api_key_encrypted
8537 .clone()
8538 .expect("ciphertext present");
8539 assert!(
8540 !enc.is_empty() && enc != "stale-ciphertext",
8541 "plaintext re-encrypted"
8542 );
8543 }
8544
8545 #[test]
8546 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8547 let mut config = Config::default();
8553 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8554 "provider_type": "openai",
8555 "api_key": "sk-instance-live",
8556 }))
8557 .expect("valid instance");
8558 config
8559 .provider_instances
8560 .insert("work".to_string(), instance);
8561
8562 config.refresh_encrypted_secrets().expect("refresh");
8563 assert!(
8564 config.provider_instances["work"]
8565 .api_key_encrypted
8566 .is_some(),
8567 "live config must hold ciphertext after refresh"
8568 );
8569
8570 let value = serde_json::to_value(&config).expect("serialize");
8572 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8573 assert!(
8574 back.provider_instances["work"].api_key.is_empty(),
8575 "plaintext is skip_serializing"
8576 );
8577 back.hydrate_provider_instance_api_keys_from_encrypted();
8578 assert_eq!(
8579 back.provider_instances["work"].api_key, "sk-instance-live",
8580 "key must be recoverable from the round-tripped ciphertext"
8581 );
8582 }
8583
8584 #[test]
8585 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8586 let mut config = Config::default();
8587 config.features.provider_model_ref = false;
8588 config.provider = "openai".to_string();
8589 config.providers.openai = Some(OpenAIConfig {
8590 api_key: "test".to_string(),
8591 api_key_encrypted: None,
8592 credential_ref: None,
8593 base_url: None,
8594 model: Some("gpt-main".to_string()),
8595 fast_model: Some("gpt-fast".to_string()),
8596 vision_model: None,
8597 reasoning_effort: None,
8598 responses_only_models: vec![],
8599 request_overrides: None,
8600 extra: BTreeMap::new(),
8601 api_key_from_env: false,
8602 });
8603
8604 assert_eq!(
8605 config.get_memory_background_model().as_deref(),
8606 Some("gpt-fast")
8607 );
8608 }
8609
8610 #[test]
8611 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8612 let mut config = Config::default();
8613 config.features.provider_model_ref = false;
8614 config.provider = "openai".to_string();
8615 config.providers.openai = Some(OpenAIConfig {
8616 api_key: "sk-stale".to_string(),
8617 model: Some("legacy-main".to_string()),
8618 fast_model: Some("legacy-fast".to_string()),
8619 vision_model: Some("legacy-vision".to_string()),
8620 reasoning_effort: Some(ReasoningEffort::Low),
8621 ..OpenAIConfig::default()
8622 });
8623 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8624 "provider_type": "openai",
8625 "model": "instance-main",
8626 "fast_model": "instance-fast",
8627 "vision_model": "instance-vision",
8628 "reasoning_effort": "high",
8629 "enabled": true
8630 }))
8631 .unwrap();
8632 instance.api_key = "sk-instance".to_string();
8633 config
8634 .provider_instances
8635 .insert("work".to_string(), instance);
8636 config.default_provider_instance = Some("work".to_string());
8637
8638 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8639 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8640 assert_eq!(
8641 config.get_memory_background_model().as_deref(),
8642 Some("instance-fast")
8643 );
8644 assert_eq!(
8645 config.get_task_summary_model().as_deref(),
8646 Some("instance-fast")
8647 );
8648 assert_eq!(
8649 config.get_vision_model().as_deref(),
8650 Some("instance-vision")
8651 );
8652 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8653 }
8654
8655 #[test]
8656 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8657 let mut config = Config::default();
8658 config.features.provider_model_ref = true;
8659 config.provider_instances.insert(
8660 "compatibility-default".to_string(),
8661 serde_json::from_value(serde_json::json!({
8662 "provider_type": "openai",
8663 "reasoning_effort": "low",
8664 "enabled": true
8665 }))
8666 .unwrap(),
8667 );
8668 config.provider_instances.insert(
8669 "chat-provider".to_string(),
8670 serde_json::from_value(serde_json::json!({
8671 "provider_type": "anthropic",
8672 "reasoning_effort": "high",
8673 "enabled": true
8674 }))
8675 .unwrap(),
8676 );
8677 config.default_provider_instance = Some("compatibility-default".to_string());
8678 config.defaults = Some(
8679 serde_json::from_value(serde_json::json!({
8680 "chat": { "provider": "chat-provider", "model": "claude" }
8681 }))
8682 .unwrap(),
8683 );
8684
8685 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8686
8687 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8688 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8689 }
8690
8691 #[test]
8692 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8693 let _provider =
8694 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8695 let _openai_key = crate::test_support::override_runtime_env_var(
8696 "BAMBOO_OPENAI_API_KEY",
8697 Some("sk-runtime-env-instance"),
8698 );
8699 let _anthropic_key =
8700 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8701 let _gemini_key =
8702 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8703
8704 let mut config = Config::default();
8705 *config.providers_mut() = ProviderConfigs::default();
8706 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8707 let mut extra = serde_json::Map::new();
8708 if from_environment {
8709 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8710 }
8711 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8712 extra.insert("enabled".to_string(), serde_json::json!(true));
8713 config.provider_instances.insert(
8714 id.to_string(),
8715 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8716 );
8717 }
8718
8719 config.apply_runtime_env_overrides();
8720
8721 assert_eq!(
8722 config.default_provider_instance.as_deref(),
8723 Some("a-marked"),
8724 "type override selects the lexicographically first enabled instance"
8725 );
8726 assert_eq!(
8727 config.provider_instances["a-marked"].api_key,
8728 "sk-runtime-env-instance"
8729 );
8730 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8731 assert!(
8732 config.providers().openai.is_none(),
8733 "instance-mode env hydration must not recreate a legacy alias"
8734 );
8735
8736 config.refresh_encrypted_secrets().unwrap();
8737 let serialized = serde_json::to_string(&config).unwrap();
8738 assert!(!serialized.contains("sk-runtime-env-instance"));
8739 assert!(
8740 config.provider_instances["a-marked"]
8741 .api_key_encrypted
8742 .is_none(),
8743 "runtime env plaintext must not be encrypted into ordinary config"
8744 );
8745 }
8746
8747 #[test]
8748 fn runtime_provider_override_prefers_exact_instance_id() {
8749 let _provider =
8750 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8751 let _openai_key =
8752 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8753 let _anthropic_key =
8754 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8755 let _gemini_key =
8756 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8757 let mut config = Config::default();
8758 for id in ["work", "personal"] {
8759 config.provider_instances.insert(
8760 id.to_string(),
8761 serde_json::from_value(serde_json::json!({
8762 "provider_type": "openai",
8763 "enabled": true
8764 }))
8765 .unwrap(),
8766 );
8767 }
8768
8769 config.apply_runtime_env_overrides();
8770 assert_eq!(
8771 config.default_provider_instance.as_deref(),
8772 Some("personal")
8773 );
8774 }
8775
8776 #[test]
8777 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
8778 let mut config = Config::default();
8779 config.features.provider_model_ref = false;
8780 config.provider = "openai".to_string();
8781 config.providers.openai = Some(OpenAIConfig {
8782 api_key: "test".to_string(),
8783 api_key_encrypted: None,
8784 credential_ref: None,
8785 base_url: None,
8786 model: Some("gpt-main".to_string()),
8787 fast_model: None,
8788 vision_model: None,
8789 reasoning_effort: None,
8790 responses_only_models: vec![],
8791 request_overrides: None,
8792 extra: BTreeMap::new(),
8793 api_key_from_env: false,
8794 });
8795
8796 assert_eq!(
8797 config.get_memory_background_model().as_deref(),
8798 Some("gpt-main")
8799 );
8800 }
8801
8802 #[test]
8803 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8804 let legacy = serde_json::json!({
8805 "memory": MemoryConfig {
8806 background_model: Some("dream-fast".to_string()),
8807 summary_target_ratio: 0.20,
8808 summary_safe_window_percent: 80,
8809 auto_dream_enabled: true,
8810 auto_dream_interval_secs: 900,
8811 project_prompt_injection: false,
8812 relevant_recall: false,
8813 relevant_recall_rerank: true,
8814 project_first_dream: false,
8815 ledger_agenda_injection: false,
8816 ledger_gardener_enabled: false,
8817 ledger_gardener_interval_secs: 7_200,
8818 ledger_distillation_enabled: false,
8819 dream_refine_mode: true,
8820 gardener_enabled: true,
8821 gardener_interval_secs: 3_600,
8822 gardener_volume_trigger: 40,
8823 gardener_max_splits_per_run: 4,
8824 gardener_min_sections: 7,
8825 dedup_gardener_enabled: true,
8826 dedup_gardener_min_score: 0.7,
8827 dedup_gardener_max_merges_per_run: 3,
8828 memory_active_capacity: 500,
8829 capacity_max_archivals_per_run: 10,
8830 granularity_freshness_gardener_enabled: false,
8831 }
8832 });
8833 let config: Config = serde_json::from_value(legacy).unwrap();
8834
8835 let serialized = serde_json::to_value(&config).expect("config should serialize");
8836 assert!(serialized.get("memory").is_some());
8837 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8838 assert!(round_tripped
8839 .memory()
8840 .as_ref()
8841 .is_some_and(|memory| memory.dream_refine_mode));
8842 let memory = config.memory.as_ref().expect("memory config should exist");
8843 assert!(memory.auto_dream_enabled);
8844 assert!(!memory.project_prompt_injection);
8845 assert!(!memory.relevant_recall);
8846 assert!(memory.relevant_recall_rerank);
8847 assert!(!memory.project_first_dream);
8848 assert!(memory.dream_refine_mode);
8849 assert!(memory.gardener_enabled);
8850 assert_eq!(memory.gardener_interval_secs, 3_600);
8851 assert_eq!(memory.gardener_volume_trigger, 40);
8852 assert_eq!(memory.gardener_max_splits_per_run, 4);
8853 assert_eq!(memory.gardener_min_sections, 7);
8854 assert!(memory.dedup_gardener_enabled);
8855 assert_eq!(memory.dedup_gardener_min_score, 0.7);
8856 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8857 assert_eq!(memory.memory_active_capacity, 500);
8858 assert_eq!(memory.capacity_max_archivals_per_run, 10);
8859 assert!(!memory.granularity_freshness_gardener_enabled);
8860 }
8861
8862 #[test]
8864 fn memory_active_capacity_defaults_off() {
8865 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8866 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8867 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8868 let memory = parsed.memory.as_ref().unwrap();
8869 assert_eq!(memory.memory_active_capacity, 0);
8870 assert_eq!(
8871 memory.capacity_max_archivals_per_run, 50,
8872 "omitted field takes the serde default fn"
8873 );
8874 }
8875
8876 #[test]
8877 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8878 let defaults = MemoryConfig::default();
8879 assert_eq!(defaults.summary_target_ratio, 0.20);
8880 assert_eq!(defaults.summary_safe_window_percent, 80);
8881
8882 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8883 let memory = parsed.memory.as_ref().expect("memory present");
8884 assert_eq!(memory.summary_target_ratio, 0.20);
8885 assert_eq!(memory.summary_safe_window_percent, 80);
8886 }
8887
8888 #[test]
8892 fn memory_maintenance_integrators_default_on() {
8893 let defaults = MemoryConfig::default();
8894 assert!(defaults.auto_dream_enabled);
8895 assert!(defaults.gardener_enabled);
8896 assert!(defaults.dedup_gardener_enabled);
8897 assert_eq!(defaults.gardener_volume_trigger, 25);
8898
8899 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8901 let memory = parsed.memory.as_ref().expect("memory present");
8902 assert!(
8903 memory.auto_dream_enabled,
8904 "auto_dream on when field omitted"
8905 );
8906 assert!(memory.gardener_enabled, "gardener on when field omitted");
8907 assert!(
8908 memory.dedup_gardener_enabled,
8909 "dedup gardener on when field omitted"
8910 );
8911 let opted_out: Config =
8913 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8914 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8915 }
8916
8917 #[test]
8918 fn memory_config_env_overrides_prompt_flags() {
8919 let _lock = env_lock_acquire();
8920 let temp_home = TempHome::new();
8921 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8922 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8923 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8924 let _relevant_recall_rerank =
8925 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8926 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8927
8928 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8929 let memory = config
8930 .memory
8931 .as_ref()
8932 .expect("memory config should be created by env overrides");
8933 assert!(!memory.project_prompt_injection);
8934 assert!(!memory.relevant_recall);
8935 assert!(memory.relevant_recall_rerank);
8936 assert!(!memory.project_first_dream);
8937 }
8938
8939 #[test]
8940 fn provider_api_keys_injected_from_env_and_never_persisted() {
8941 let _lock = env_lock_acquire();
8942 let temp_home = TempHome::new();
8943 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8944 let _anthropic = crate::test_support::override_runtime_env_var(
8945 "BAMBOO_ANTHROPIC_API_KEY",
8946 Some("sk-ant-from-env"),
8947 );
8948 let _openai = crate::test_support::override_runtime_env_var(
8949 "BAMBOO_OPENAI_API_KEY",
8950 Some("sk-oai-from-env"),
8951 );
8952 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8953
8954 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8957 assert_eq!(
8958 config
8959 .providers
8960 .anthropic
8961 .as_ref()
8962 .expect("anthropic created from env")
8963 .api_key,
8964 "sk-ant-from-env"
8965 );
8966 assert_eq!(
8967 config
8968 .providers
8969 .openai
8970 .as_ref()
8971 .expect("openai created from env")
8972 .api_key,
8973 "sk-oai-from-env"
8974 );
8975 assert!(config.providers.gemini.is_none());
8977
8978 config
8984 .save_to_dir(temp_home.path.clone())
8985 .expect("save config");
8986 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8987 .expect("read persisted config.json");
8988 assert!(
8989 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8990 "env key must not be persisted as plaintext"
8991 );
8992 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8993 assert!(
8994 disk_json["providers"]["anthropic"]
8995 .get("api_key_encrypted")
8996 .is_none(),
8997 "env-sourced anthropic key must not be re-encrypted into config.json"
8998 );
8999 assert!(
9000 disk_json["providers"]["openai"]
9001 .get("api_key_encrypted")
9002 .is_none(),
9003 "env-sourced openai key must not be re-encrypted into config.json"
9004 );
9005
9006 drop(_anthropic);
9009 drop(_openai);
9010 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9011 assert!(reloaded
9012 .providers
9013 .anthropic
9014 .as_ref()
9015 .map(|a| a.api_key.is_empty())
9016 .unwrap_or(true));
9017 }
9018
9019 #[test]
9020 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9021 let _lock = env_lock_acquire();
9022 let temp_home = TempHome::new();
9023 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9024 let target = temp_home.path.join("workspace-default");
9025 std::fs::create_dir_all(&target).expect("default work area dir should exist");
9026
9027 let mut config = Config::default();
9028 config.default_work_area.replace(DefaultWorkAreaConfig {
9029 path: Some("~/workspace-default".to_string()),
9030 });
9031
9032 assert_eq!(config.get_default_work_area_path(), Some(target));
9033 }
9034
9035 #[test]
9036 fn get_default_work_area_path_returns_none_for_missing_directory() {
9037 let _lock = env_lock_acquire();
9038 let temp_home = TempHome::new();
9039 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9040
9041 let mut config = Config::default();
9042 config.default_work_area.replace(DefaultWorkAreaConfig {
9043 path: Some("~/missing-default-work-area".to_string()),
9044 });
9045
9046 assert!(config.get_default_work_area_path().is_none());
9047 }
9048
9049 #[test]
9050 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9051 let mut config = Config::default();
9052 config.tools.disabled = vec![
9053 " read_file ".to_string(),
9054 "".to_string(),
9055 "read_file".to_string(),
9056 "bash".to_string(),
9057 "default::getCurrentDir".to_string(),
9058 "default::applyPatch".to_string(),
9059 "default::custom_tool".to_string(),
9060 "mcp__alpha__inspect".to_string(),
9061 ];
9062
9063 config.normalize_tool_settings();
9064
9065 assert_eq!(
9066 config.tools.disabled,
9067 vec![
9068 "bash",
9069 "default::applyPatch",
9070 "default::custom_tool",
9071 "default::getCurrentDir",
9072 "mcp__alpha__inspect",
9073 "read_file"
9074 ]
9075 );
9076 }
9077
9078 #[test]
9079 fn config_load_preserves_disabled_references_for_catalog_resolution() {
9080 let _lock = env_lock_acquire();
9081 let temp_home = TempHome::new();
9082 temp_home.set_config_json(
9083 r#"{
9084 "tools": {
9085 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9086 }
9087}"#,
9088 );
9089
9090 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9091 assert_eq!(
9092 config.tools.disabled,
9093 vec!["bash", "default::getCurrentDir", "read_file"]
9094 );
9095 assert!(config.disabled_tool_references().contains("bash"));
9096 assert!(config.disabled_tool_references().contains("read_file"));
9097 assert!(config
9098 .disabled_tool_references()
9099 .contains("default::getCurrentDir"));
9100 assert_eq!(
9101 config.disabled_tool_names(),
9102 BTreeSet::from([
9103 "Bash".to_string(),
9104 "GetCurrentDir".to_string(),
9105 "Read".to_string()
9106 ])
9107 );
9108 }
9109
9110 #[test]
9111 fn normalize_skill_settings_trims_dedupes_and_sorts() {
9112 let mut config = Config::default();
9113 config.skills.disabled = vec![
9114 " pdf ".to_string(),
9115 "".to_string(),
9116 "pdf".to_string(),
9117 "skill-creator".to_string(),
9118 ];
9119
9120 config.normalize_skill_settings();
9121
9122 assert_eq!(
9123 config.skills.disabled,
9124 vec!["pdf".to_string(), "skill-creator".to_string()]
9125 );
9126 }
9127
9128 #[test]
9129 fn config_load_reads_disabled_skills_as_normalized_ids() {
9130 let _lock = env_lock_acquire();
9131 let temp_home = TempHome::new();
9132 temp_home.set_config_json(
9133 r#"{
9134 "skills": {
9135 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9136 }
9137}"#,
9138 );
9139
9140 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9141 assert_eq!(
9142 config.skills.disabled,
9143 vec!["pdf".to_string(), "skill-creator".to_string()]
9144 );
9145 assert!(config.disabled_skill_ids().contains("pdf"));
9146 assert!(config.disabled_skill_ids().contains("skill-creator"));
9147 }
9148
9149 #[test]
9150 fn test_server_config_defaults() {
9151 let _lock = env_lock_acquire();
9152 let temp_home = TempHome::new();
9153
9154 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9155 assert_eq!(config.server.port, 9562);
9156 assert_eq!(config.server.bind, "127.0.0.1");
9157 assert_eq!(config.server.workers, 10);
9158 assert!(config.server.static_dir.is_none());
9159 }
9160
9161 #[test]
9162 fn test_server_addr() {
9163 let mut config = Config::default();
9164 config.server.port = 9000;
9165 config.server.bind = "0.0.0.0".to_string();
9166 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9167 }
9168
9169 #[test]
9170 fn test_env_var_overrides() {
9171 let _lock = env_lock_acquire();
9172 let temp_home = TempHome::new();
9173
9174 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9175 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9176 let _provider =
9177 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9178
9179 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9180 assert_eq!(config.server.port, 9999);
9181 assert_eq!(config.server.bind, "192.168.1.1");
9182 assert_eq!(config.provider, "openai");
9183 }
9184
9185 #[test]
9186 fn test_config_save_and_load() {
9187 let _lock = env_lock_acquire();
9188 let temp_home = TempHome::new();
9189
9190 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9191 config.server.port = 9000;
9192 config.server.bind = "0.0.0.0".to_string();
9193 config.provider = "anthropic".to_string();
9194
9195 config
9197 .save_to_dir(temp_home.path.clone())
9198 .expect("Failed to save config");
9199
9200 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9202
9203 assert_eq!(loaded.server.port, 9000);
9205 assert_eq!(loaded.server.bind, "0.0.0.0");
9206 assert_eq!(loaded.provider, "anthropic");
9207 }
9208
9209 #[test]
9210 fn modular_root_and_public_serde_preserve_legacy_shape() {
9211 let _lock = env_lock_acquire();
9212 let temp_home = TempHome::new();
9213 let input = serde_json::json!({
9214 "http_proxy": "http://proxy.example",
9215 "provider": "openai",
9216 "mcp": { "servers": [] },
9217 "future_extension": { "enabled": true },
9218 "memory": { "background_model": "memory-model" },
9219 "subagents": { "max_concurrent": 7 },
9220 "providers": { "openai": { "model": "chat-model" } }
9221 });
9222
9223 let config: Config = serde_json::from_value(input).unwrap();
9224 let public = serde_json::to_value(&config).unwrap();
9225 assert_eq!(public["http_proxy"], "http://proxy.example");
9226 assert!(public.get("mcp").is_none());
9227 assert!(public.get("mcpServers").is_some());
9228 assert_eq!(public["future_extension"]["enabled"], true);
9229 assert_eq!(public["memory"]["background_model"], "memory-model");
9230 assert_eq!(public["subagents"]["max_concurrent"], 7);
9231 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9232
9233 let round_tripped: Config = serde_json::from_value(public).unwrap();
9234 assert_eq!(
9235 round_tripped
9236 .memory()
9237 .as_ref()
9238 .unwrap()
9239 .background_model
9240 .as_deref(),
9241 Some("memory-model")
9242 );
9243 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9244 assert_eq!(
9245 round_tripped
9246 .providers()
9247 .openai
9248 .as_ref()
9249 .unwrap()
9250 .model
9251 .as_deref(),
9252 Some("chat-model")
9253 );
9254 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9255
9256 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9257 let persisted: Value =
9258 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9259 .unwrap();
9260 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9261 assert!(persisted.get("mcpServers").is_some());
9262 assert_eq!(persisted["future_extension"]["enabled"], true);
9263 assert!(persisted.get("memory").is_none());
9264 assert!(persisted.get("subagents").is_none());
9265 assert!(persisted.get("providers").is_none());
9266 for internal_section_name in [
9267 "network",
9268 "provider_routing",
9269 "model_behavior",
9270 "tooling",
9271 "workspace",
9272 "execution",
9273 "integrations",
9274 "plugin_security",
9275 ] {
9276 assert!(persisted.get(internal_section_name).is_none());
9277 }
9278 }
9279
9280 #[test]
9281 fn config_decrypts_proxy_auth_from_encrypted_field() {
9282 let _lock = env_lock_acquire();
9283 let temp_home = TempHome::new();
9284
9285 let key_guard = crate::encryption::set_test_encryption_key([
9287 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9288 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9289 0x1c, 0x1d, 0x1e, 0x1f,
9290 ]);
9291
9292 let auth = ProxyAuth {
9293 username: "user".to_string(),
9294 password: "pass".to_string(),
9295 };
9296 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9297 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9298
9299 temp_home.set_config_json(&format!(
9300 r#"{{
9301 "http_proxy": "http://proxy.example.com:8080",
9302 "proxy_auth_encrypted": "{encrypted}"
9303}}"#
9304 ));
9305 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9306 let loaded_auth = config
9307 .proxy_auth
9308 .as_ref()
9309 .expect("proxy auth should be hydrated");
9310 assert_eq!(loaded_auth.username, "user");
9311 assert_eq!(loaded_auth.password, "pass");
9312 drop(key_guard);
9313 }
9314
9315 #[test]
9316 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9317 let _lock = env_lock_acquire();
9318 let temp_home = TempHome::new();
9319
9320 let key_guard = crate::encryption::set_test_encryption_key([
9322 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9323 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9324 0x1c, 0x1d, 0x1e, 0x1f,
9325 ]);
9326
9327 let auth = ProxyAuth {
9328 username: "user".to_string(),
9329 password: "pass".to_string(),
9330 };
9331 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9332 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9333
9334 temp_home.set_config_json(&format!(
9336 r#"{{
9337 "http_proxy": "http://proxy.example.com:8080",
9338 "http_proxy_auth_encrypted": "{encrypted}",
9339 "https_proxy_auth_encrypted": "{encrypted}"
9340}}"#
9341 ));
9342
9343 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9344 let loaded_auth = config
9345 .proxy_auth
9346 .as_ref()
9347 .expect("proxy auth should be hydrated");
9348 assert_eq!(loaded_auth.username, "user");
9349 assert_eq!(loaded_auth.password, "pass");
9350 drop(key_guard);
9351 }
9352
9353 #[test]
9354 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9355 let _lock = env_lock_acquire();
9356 let temp_home = TempHome::new();
9357
9358 let key_guard = crate::encryption::set_test_encryption_key([
9360 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9361 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9362 0x1c, 0x1d, 0x1e, 0x1f,
9363 ]);
9364
9365 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9366 config.proxy_auth = Some(ProxyAuth {
9367 username: "user".to_string(),
9368 password: "pass".to_string(),
9369 });
9370 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9371 assert!(error
9372 .to_string()
9373 .contains("isolated credential transaction"));
9374 let path = temp_home.path.join("config.json");
9375 assert!(
9376 !path.exists(),
9377 "a rejected unisolated secret must not create config.json"
9378 );
9379 drop(key_guard);
9380 }
9381
9382 #[test]
9383 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9384 let _lock = env_lock_acquire();
9385 let temp_home = TempHome::new();
9386 let mut config = Config::default();
9387 config.env_vars.push(EnvVarEntry {
9388 name: "TOKEN".to_string(),
9389 value: String::new(),
9390 secret: true,
9391 value_encrypted: None,
9392 credential_ref: None,
9393 configured: true,
9394 description: None,
9395 });
9396 let path = temp_home.path.join("config.json");
9397
9398 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9399 assert!(error
9400 .to_string()
9401 .contains("isolated credential transaction"));
9402 assert!(
9403 !path.exists(),
9404 "a rejected dangling ref must not create config.json"
9405 );
9406
9407 let original = br#"{"preserve":"original"}"#;
9408 std::fs::write(&path, original).unwrap();
9409 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9410 assert_eq!(
9411 std::fs::read(&path).unwrap(),
9412 original,
9413 "a rejected dangling ref must not modify an existing config.json"
9414 );
9415 }
9416
9417 #[test]
9418 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9419 let _lock = env_lock_acquire();
9420 let temp_home = TempHome::new();
9421
9422 let key_guard = crate::encryption::set_test_encryption_key([
9424 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9425 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9426 0x1c, 0x1d, 0x1e, 0x1f,
9427 ]);
9428
9429 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9430 crate::CredentialStore::open(&temp_home.path)
9431 .replace(
9432 reference.clone(),
9433 "sk-test-provider-key",
9434 crate::CredentialSource::User,
9435 0,
9436 )
9437 .unwrap();
9438 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9439 config.provider = "openai".to_string();
9440 config.providers.openai = Some(OpenAIConfig {
9441 api_key: "sk-test-provider-key".to_string(),
9442 api_key_encrypted: None,
9443 credential_ref: Some(reference),
9444 base_url: None,
9445 model: None,
9446 fast_model: None,
9447 vision_model: None,
9448 reasoning_effort: None,
9449 responses_only_models: vec![],
9450 request_overrides: None,
9451 extra: Default::default(),
9452 api_key_from_env: false,
9453 });
9454
9455 config
9456 .save_to_dir(temp_home.path.clone())
9457 .expect("save should persist provider reference");
9458
9459 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9460 .expect("read providers.json");
9461 assert!(
9462 !content.contains("api_key_encrypted"),
9463 "providers.json must not store provider ciphertext"
9464 );
9465 assert!(
9466 !content.contains("\"api_key\""),
9467 "providers.json should not store plaintext provider keys"
9468 );
9469
9470 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9471 let openai = loaded
9472 .providers
9473 .openai
9474 .as_ref()
9475 .expect("openai config should be present");
9476 assert_eq!(openai.api_key, "sk-test-provider-key");
9477
9478 drop(key_guard);
9479 }
9480
9481 #[test]
9482 fn config_save_persists_mcp_servers_in_mainstream_format() {
9483 let _lock = env_lock_acquire();
9484 let temp_home = TempHome::new();
9485
9486 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9487
9488 let mut env = std::collections::HashMap::new();
9489 env.insert("TOKEN".to_string(), "supersecret".to_string());
9490
9491 config.mcp.servers = vec![
9492 bamboo_domain::mcp_config::McpServerConfig {
9493 id: "stdio-secret".to_string(),
9494 name: None,
9495 enabled: true,
9496 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9497 bamboo_domain::mcp_config::StdioConfig {
9498 command: "echo".to_string(),
9499 args: vec![],
9500 cwd: None,
9501 env,
9502 env_encrypted: std::collections::HashMap::new(),
9503 env_credential_refs: std::collections::HashMap::new(),
9504 startup_timeout_ms: 5000,
9505 },
9506 ),
9507 request_timeout_ms: 5000,
9508 healthcheck_interval_ms: 1000,
9509 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9510 allowed_tools: vec![],
9511 denied_tools: vec![],
9512 },
9513 bamboo_domain::mcp_config::McpServerConfig {
9514 id: "http-secret".to_string(),
9515 name: None,
9516 enabled: true,
9517 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9518 bamboo_domain::mcp_config::StreamableHttpConfig {
9519 url: "http://localhost:8080/http".to_string(),
9520 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9521 name: "Authorization".to_string(),
9522 value: "Bearer token123".to_string(),
9523 value_encrypted: None,
9524 credential_ref: None,
9525 }],
9526 connect_timeout_ms: 5000,
9527 },
9528 ),
9529 request_timeout_ms: 5000,
9530 healthcheck_interval_ms: 1000,
9531 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9532 allowed_tools: vec![],
9533 denied_tools: vec![],
9534 },
9535 ];
9536
9537 config
9538 .save_to_dir(temp_home.path.clone())
9539 .expect("save should persist MCP servers");
9540
9541 let content =
9542 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9543 assert!(
9544 content.contains("\"mcpServers\""),
9545 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9546 );
9547 assert!(
9548 content.contains("supersecret"),
9549 "config.json should persist MCP stdio env in mainstream format"
9550 );
9551 assert!(
9552 content.contains("Bearer token123"),
9553 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9554 );
9555 assert!(
9556 !content.contains("\"env_encrypted\""),
9557 "config.json should not persist legacy env_encrypted fields"
9558 );
9559 assert!(
9560 !content.contains("\"value_encrypted\""),
9561 "config.json should not persist legacy value_encrypted fields"
9562 );
9563
9564 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9565 let stdio = loaded
9566 .mcp
9567 .servers
9568 .iter()
9569 .find(|s| s.id == "stdio-secret")
9570 .expect("stdio server should exist");
9571 match &stdio.transport {
9572 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9573 assert_eq!(
9574 stdio.env.get("TOKEN").map(|s| s.as_str()),
9575 Some("supersecret")
9576 );
9577 }
9578 _ => panic!("Expected stdio transport"),
9579 }
9580
9581 let http = loaded
9582 .mcp
9583 .servers
9584 .iter()
9585 .find(|s| s.id == "http-secret")
9586 .expect("http server should exist");
9587 match &http.transport {
9588 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9589 assert_eq!(http.headers[0].value, "Bearer token123");
9590 }
9591 _ => panic!("Expected Streamable HTTP transport"),
9592 }
9593 }
9594
9595 #[test]
9596 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9597 let _lock = env_lock_acquire();
9598 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9599 let temp_home = TempHome::new();
9600 let env_plaintext = "mcp-root-env-plaintext-597";
9601 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9602 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9603 std::fs::write(
9604 temp_home.path.join("config.json"),
9605 serde_json::to_vec_pretty(&serde_json::json!({
9606 "features": {"provider_model_ref": true},
9607 "mcpServers": {
9608 "stdio-root": {
9609 "command": "unused-disabled-command",
9610 "env": {"TOKEN": env_plaintext},
9611 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9612 "env_credential_refs": {
9613 "TOKEN": "mcp.stdio-root.env_TOKEN"
9614 }
9615 },
9616 "http-root": {
9617 "url": "https://example.test/mcp",
9618 "transport_kind": "streamable_http",
9619 "headers": {"Authorization": header_plaintext},
9620 "header_credential_refs": {
9621 "Authorization": "mcp.http-root.header_Authorization"
9622 }
9623 }
9624 }
9625 }))
9626 .unwrap(),
9627 )
9628 .unwrap();
9629 std::fs::write(
9630 temp_home.path.join("mcp.json"),
9631 serde_json::to_vec_pretty(&serde_json::json!({
9632 "schema_version": 1,
9633 "revision": 7,
9634 "data": {
9635 "stdio-root": {
9636 "command": "unused-disabled-command",
9637 "enabled": false,
9638 "env_encrypted": {"TOKEN": legacy_ciphertext},
9639 "request_timeout_ms": 100,
9640 "healthcheck_interval_ms": 100
9641 },
9642 "http-root": {
9643 "url": "https://example.test/mcp",
9644 "transport_kind": "streamable_http",
9645 "enabled": false,
9646 "headers": {"Authorization": header_plaintext},
9647 "request_timeout_ms": 100,
9648 "healthcheck_interval_ms": 100
9649 }
9650 }
9651 }))
9652 .unwrap(),
9653 )
9654 .unwrap();
9655
9656 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9657 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9658 temp_home.path.join("mcp.json"),
9659 1,
9660 )
9661 .load()
9662 .unwrap()
9663 .unwrap();
9664 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9665 config.mcp = stored.data;
9666 config
9667 .hydrate_mcp_credentials_from_store(&temp_home.path)
9668 .unwrap();
9669
9670 let public = serde_json::to_value(&config.mcp).unwrap();
9671 let rendered_public = public.to_string();
9672 assert!(rendered_public.contains(env_plaintext));
9673 assert!(rendered_public.contains(header_plaintext));
9674 let compatible: bamboo_domain::mcp_config::McpConfig =
9675 serde_json::from_value(public).unwrap();
9676 assert_eq!(compatible.servers.len(), 2);
9677
9678 config.features.provider_model_ref = !config.features.provider_model_ref;
9679 config.save_to_dir(temp_home.path.clone()).unwrap();
9680 config.features.provider_model_ref = !config.features.provider_model_ref;
9681 config.save_to_dir(temp_home.path.clone()).unwrap();
9682
9683 for entry in std::fs::read_dir(&temp_home.path)
9684 .unwrap()
9685 .filter_map(Result::ok)
9686 {
9687 let name = entry.file_name().to_string_lossy().to_string();
9688 if name == "credentials.json" || entry.path().is_dir() {
9689 continue;
9690 }
9691 if name == "config.json"
9692 || name == "mcp.json"
9693 || name.contains(".bak")
9694 || name.starts_with("config-credential-migration")
9695 {
9696 let bytes = std::fs::read(entry.path()).unwrap();
9697 let content = String::from_utf8_lossy(&bytes);
9698 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9699 assert!(
9700 !content.contains(header_plaintext),
9701 "secret leaked to {name}"
9702 );
9703 assert!(
9704 !content.contains(&legacy_ciphertext),
9705 "legacy ciphertext leaked to {name}"
9706 );
9707 }
9708 }
9709 }
9710
9711 #[test]
9714 fn env_vars_as_map_includes_only_non_empty_values() {
9715 let mut config = Config::default();
9716 config.env_vars.extend([
9717 EnvVarEntry {
9718 name: "A".to_string(),
9719 value: "val_a".to_string(),
9720 secret: false,
9721 value_encrypted: None,
9722 credential_ref: None,
9723 configured: true,
9724 description: None,
9725 },
9726 EnvVarEntry {
9727 name: "B".to_string(),
9728 value: "".to_string(), secret: true,
9730 value_encrypted: None,
9731 credential_ref: None,
9732 configured: false,
9733 description: None,
9734 },
9735 EnvVarEntry {
9736 name: "C".to_string(),
9737 value: " ".to_string(), secret: false,
9739 value_encrypted: None,
9740 credential_ref: None,
9741 configured: true,
9742 description: None,
9743 },
9744 EnvVarEntry {
9745 name: "D".to_string(),
9746 value: "val_d".to_string(),
9747 secret: true,
9748 value_encrypted: Some("enc".to_string()),
9749 credential_ref: None,
9750 configured: true,
9751 description: Some("desc".to_string()),
9752 },
9753 ]);
9754
9755 let map = config.env_vars_as_map();
9756 assert_eq!(map.len(), 2);
9757 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9758 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9759 assert!(!map.contains_key("B"));
9760 assert!(!map.contains_key("C"));
9761 }
9762
9763 #[test]
9764 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9765 let mut config = Config::default();
9766 config.env_vars.extend([
9767 EnvVarEntry {
9768 name: "PLAIN".to_string(),
9769 value: "visible".to_string(),
9770 secret: false,
9771 value_encrypted: None,
9772 credential_ref: None,
9773 configured: true,
9774 description: None,
9775 },
9776 EnvVarEntry {
9777 name: "SECRET".to_string(),
9778 value: "hidden_value".to_string(),
9779 secret: true,
9780 value_encrypted: Some("enc_data".to_string()),
9781 credential_ref: None,
9782 configured: true,
9783 description: None,
9784 },
9785 ]);
9786
9787 config.sanitize_env_vars_for_disk();
9788
9789 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
9792
9793 #[test]
9794 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9795 let mut config = Config::default();
9796 config.env_vars.extend([
9797 EnvVarEntry {
9798 name: "OPEN".to_string(),
9799 value: "val".to_string(),
9800 secret: false,
9801 value_encrypted: None,
9802 credential_ref: None,
9803 configured: true,
9804 description: None,
9805 },
9806 EnvVarEntry {
9807 name: "HIDDEN".to_string(),
9808 value: "real_secret".to_string(),
9809 secret: true,
9810 value_encrypted: Some("enc".to_string()),
9811 credential_ref: None,
9812 configured: true,
9813 description: None,
9814 },
9815 ]);
9816
9817 config.sanitize_env_vars_for_disk();
9818
9819 assert_eq!(config.env_vars[0].value, "val");
9821 assert_eq!(config.env_vars[1].value, "");
9823 assert!(config.env_vars[1].value_encrypted.is_none());
9824 }
9825
9826 #[test]
9827 fn legacy_env_ciphertext_is_read_but_never_serialized() {
9828 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9829 let mut config: Config = serde_json::from_value(serde_json::json!({
9830 "env_vars": [{
9831 "name": "TOKEN",
9832 "secret": true,
9833 "value_encrypted": ciphertext
9834 }]
9835 }))
9836 .unwrap();
9837 config.hydrate_env_vars_from_encrypted();
9838 assert_eq!(config.env_vars[0].value, "my-secret-token");
9839 let serialized = serde_json::to_value(&config).unwrap();
9840 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9841 }
9842
9843 #[test]
9844 fn configured_env_ref_missing_from_store_fails_closed() {
9845 let dir = tempfile::tempdir().unwrap();
9846 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9847 let mut config = Config::default();
9848 config.env_vars.push(EnvVarEntry {
9849 name: "TOKEN".to_string(),
9850 value: String::new(),
9851 secret: true,
9852 value_encrypted: None,
9853 credential_ref: Some(reference),
9854 configured: true,
9855 description: None,
9856 });
9857 let error = config
9858 .hydrate_env_var_credentials_from_store(dir.path())
9859 .unwrap_err();
9860 assert!(error
9861 .to_string()
9862 .contains("referenced env credential is unavailable"));
9863 assert!(config.env_vars[0].configured);
9864 assert!(config.env_vars[0].value.is_empty());
9865 }
9866
9867 #[test]
9868 fn configured_notification_ref_missing_from_store_fails_closed() {
9869 let dir = tempfile::tempdir().unwrap();
9870 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9871 let mut config = Config::default();
9872 config.notifications.ntfy.credential_ref = Some(reference);
9873 config.notifications.ntfy.configured = true;
9874
9875 let error = config
9876 .hydrate_notification_credentials_from_store(dir.path())
9877 .unwrap_err();
9878
9879 assert!(error
9880 .to_string()
9881 .contains("referenced ntfy credential is unavailable"));
9882 assert!(config.notifications.ntfy.configured);
9883 assert!(config.notifications.ntfy.token.is_none());
9884 }
9885
9886 #[test]
9887 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9888 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9889 let dir = tempfile::tempdir().unwrap();
9890 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9891 crate::CredentialStore::open(dir.path())
9892 .replace(
9893 reference.clone(),
9894 "exclusive-notification-secret",
9895 crate::CredentialSource::User,
9896 0,
9897 )
9898 .unwrap();
9899
9900 let mut config = Config::default();
9901 config.notifications.ntfy.credential_ref = Some(reference.clone());
9902 config.notifications.ntfy.configured = true;
9903 config.proxy_auth_credential_ref = Some(reference.clone());
9904 let error = config
9905 .hydrate_notification_credentials_from_store(dir.path())
9906 .unwrap_err();
9907 let rendered = error.to_string();
9908 assert!(rendered
9909 .contains("notification credential reference is shared by another config consumer"));
9910 assert!(!rendered.contains(reference.as_str()));
9911 assert!(config.notifications.ntfy.token.is_none());
9912
9913 config.proxy_auth_credential_ref = None;
9914 config
9915 .hydrate_notification_credentials_from_store(dir.path())
9916 .unwrap();
9917 assert_eq!(
9918 config.notifications.ntfy.token.as_deref(),
9919 Some("exclusive-notification-secret")
9920 );
9921 }
9922
9923 #[test]
9924 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9925 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9926 let dir = tempfile::tempdir().unwrap();
9927 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9928 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9929 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9930 "notifications": {
9931 "ntfy": { "enabled": true, "token_encrypted": ntfy },
9932 "bark": { "enabled": true, "device_key_encrypted": bark }
9933 }
9934 }))
9935 .unwrap();
9936 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9937 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9938
9939 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9940
9941 assert!(loaded.notifications.ntfy.token.is_none());
9942 assert!(loaded.notifications.bark.device_key.is_none());
9943 assert_eq!(
9944 std::fs::read(dir.path().join("config.json")).unwrap(),
9945 bytes
9946 );
9947 }
9948
9949 #[test]
9950 fn publish_and_current_env_vars_round_trip() {
9951 let _lock = crate::test_support::env_cache_lock_acquire();
9961 let mut config = Config::default();
9962 config.env_vars.extend([EnvVarEntry {
9963 name: "TEST_PUBLISH".to_string(),
9964 value: "pub_value".to_string(),
9965 secret: false,
9966 value_encrypted: None,
9967 credential_ref: None,
9968 configured: true,
9969 description: None,
9970 }]);
9971
9972 config.publish_env_vars();
9973 assert_eq!(
9974 Config::current_env_vars()
9975 .get("TEST_PUBLISH")
9976 .map(String::as_str),
9977 Some("pub_value")
9978 );
9979 }
9980
9981 #[test]
9982 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9983 let mut config = Config::default();
9984 config.subagents.broker = Some(BrokerClientConfig {
9985 endpoint: "ws://127.0.0.1:9600".to_string(),
9986 token: "super-secret-token".to_string(),
9987 token_encrypted: None,
9988 credential_ref: None,
9989 configured: false,
9990 });
9991
9992 config.refresh_broker_token_encrypted().unwrap();
9994 config.sanitize_broker_token_for_disk();
9995 let broker = config.subagents.broker.as_ref().unwrap();
9996 assert!(broker.token.is_empty(), "plaintext cleared for disk");
9997 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9998 assert_ne!(
9999 broker.token_encrypted.as_deref(),
10000 Some("super-secret-token")
10001 );
10002
10003 config.hydrate_broker_token_from_encrypted();
10005 assert_eq!(
10006 config.subagents.broker.as_ref().unwrap().token,
10007 "super-secret-token"
10008 );
10009 }
10010
10011 #[test]
10012 fn broker_token_empty_refresh_preserves_ciphertext() {
10013 let mut config = Config::default();
10015 config.subagents.broker = Some(BrokerClientConfig {
10016 endpoint: "ws://h:9600".to_string(),
10017 token: String::new(),
10018 token_encrypted: Some("existing-cipher".to_string()),
10019 credential_ref: None,
10020 configured: false,
10021 });
10022 config.refresh_broker_token_encrypted().unwrap();
10023 assert_eq!(
10024 config
10025 .subagents
10026 .broker
10027 .as_ref()
10028 .unwrap()
10029 .token_encrypted
10030 .as_deref(),
10031 Some("existing-cipher"),
10032 );
10033 }
10034
10035 #[test]
10036 fn notifications_config_defaults_when_key_missing() {
10037 let config: Config = serde_json::from_str("{}").expect("empty object parses");
10040 assert_eq!(config.notifications, NotificationsConfig::default());
10041 assert_eq!(config.notifications.desktop.enabled, None);
10042 assert!(!config.notifications.ntfy.enabled);
10043 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10044 assert_eq!(config.notifications.ntfy.token, None);
10045 assert!(!config.notifications.bark.enabled);
10046 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10047 assert_eq!(config.notifications.bark.device_key, None);
10048 }
10049
10050 #[test]
10051 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10052 let mut config = Config::default();
10053 config.notifications.ntfy = NtfyChannelConfig {
10054 enabled: true,
10055 base_url: "https://ntfy.sh".to_string(),
10056 topic: "bamboo-alerts".to_string(),
10057 token: Some("tk_super_secret".to_string()),
10058 token_encrypted: None,
10059 credential_ref: None,
10060 configured: false,
10061 };
10062
10063 config.refresh_notifications_encrypted().unwrap();
10066 assert!(config.notifications.ntfy.token_encrypted.is_some());
10067 assert_ne!(
10068 config.notifications.ntfy.token_encrypted.as_deref(),
10069 Some("tk_super_secret")
10070 );
10071
10072 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10074 assert!(
10075 !json.contains("tk_super_secret"),
10076 "plaintext token must never be serialized"
10077 );
10078 assert!(!json.contains("token_encrypted"));
10079
10080 config.notifications.ntfy.token = None;
10082 config.hydrate_notifications_from_encrypted();
10083 assert_eq!(
10084 config.notifications.ntfy.token.as_deref(),
10085 Some("tk_super_secret")
10086 );
10087 }
10088
10089 #[test]
10090 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10091 let mut config = Config::default();
10092 config.notifications.bark = BarkChannelConfig {
10093 enabled: true,
10094 base_url: "https://api.day.app".to_string(),
10095 device_key: Some("dk_super_secret".to_string()),
10096 device_key_encrypted: None,
10097 credential_ref: None,
10098 configured: false,
10099 };
10100
10101 config.refresh_notifications_encrypted().unwrap();
10102 assert!(config.notifications.bark.device_key_encrypted.is_some());
10103 assert_ne!(
10104 config.notifications.bark.device_key_encrypted.as_deref(),
10105 Some("dk_super_secret")
10106 );
10107
10108 let json = serde_json::to_string(&config.notifications.bark).unwrap();
10109 assert!(
10110 !json.contains("dk_super_secret"),
10111 "plaintext device key must never be serialized"
10112 );
10113 assert!(!json.contains("device_key_encrypted"));
10114
10115 config.notifications.bark.device_key = None;
10116 config.hydrate_notifications_from_encrypted();
10117 assert_eq!(
10118 config.notifications.bark.device_key.as_deref(),
10119 Some("dk_super_secret")
10120 );
10121 }
10122
10123 #[test]
10124 fn notification_secrets_empty_refresh_preserves_ciphertext() {
10125 let mut config = Config::default();
10128 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10129 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10130
10131 config.refresh_notifications_encrypted().unwrap();
10132
10133 assert_eq!(
10134 config.notifications.ntfy.token_encrypted.as_deref(),
10135 Some("existing-ntfy-cipher")
10136 );
10137 assert_eq!(
10138 config.notifications.bark.device_key_encrypted.as_deref(),
10139 Some("existing-bark-cipher")
10140 );
10141 }
10142
10143 #[test]
10144 fn hydrate_skips_non_secret_entries() {
10145 let mut config = Config::default();
10146 config.env_vars.extend([EnvVarEntry {
10147 name: "PLAIN".to_string(),
10148 value: "original".to_string(),
10149 secret: false,
10150 value_encrypted: Some("should-be-ignored".to_string()),
10151 credential_ref: None,
10152 configured: true,
10153 description: None,
10154 }]);
10155
10156 config.hydrate_env_vars_from_encrypted();
10157 assert_eq!(config.env_vars[0].value, "original");
10159 }
10160
10161 #[test]
10162 fn default_config_has_empty_env_vars() {
10163 assert!(Config::default().env_vars.is_empty());
10168 }
10169
10170 #[test]
10171 fn serde_round_trip_with_env_vars() {
10172 let mut config = Config::default();
10173 config.env_vars.extend([
10174 EnvVarEntry {
10175 name: "KEY1".to_string(),
10176 value: "val1".to_string(),
10177 secret: false,
10178 value_encrypted: None,
10179 credential_ref: None,
10180 configured: true,
10181 description: Some("First key".to_string()),
10182 },
10183 EnvVarEntry {
10184 name: "KEY2".to_string(),
10185 value: "".to_string(), secret: true,
10187 value_encrypted: Some("enc123".to_string()),
10188 credential_ref: None,
10189 configured: true,
10190 description: None,
10191 },
10192 ]);
10193
10194 let json = serde_json::to_string(&config).unwrap();
10195 let restored: Config = serde_json::from_str(&json).unwrap();
10196
10197 assert_eq!(restored.env_vars.len(), 2);
10198 assert_eq!(restored.env_vars[0].name, "KEY1");
10199 assert_eq!(restored.env_vars[0].value, "val1");
10200 assert!(!restored.env_vars[0].secret);
10201 assert_eq!(restored.env_vars[1].name, "KEY2");
10202 assert!(restored.env_vars[1].secret);
10203 assert!(restored.env_vars[1].value_encrypted.is_none());
10204 }
10205
10206 #[test]
10209 #[allow(clippy::field_reassign_with_default)]
10211 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10212 let mut config = Config::default();
10213 config.provider = "openai".to_string();
10214 config.providers.openai = Some(OpenAIConfig {
10215 api_key: "test".to_string(),
10216 api_key_encrypted: None,
10217 credential_ref: None,
10218 base_url: None,
10219 model: Some("legacy-gpt-4o".to_string()),
10220 fast_model: None,
10221 vision_model: None,
10222 reasoning_effort: None,
10223 responses_only_models: vec![],
10224 request_overrides: None,
10225 extra: Default::default(),
10226 api_key_from_env: false,
10227 });
10228 config.features.provider_model_ref = true;
10229 config.defaults = Some(DefaultsConfig {
10230 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10231 fast: None,
10232 task_summary: None,
10233 vision: None,
10234 memory_background: None,
10235 planning: None,
10236 search: None,
10237 code_review: None,
10238 sub_agent: None,
10239 subagent_models: Default::default(),
10240 });
10241
10242 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10243 }
10244
10245 #[test]
10246 #[allow(clippy::field_reassign_with_default)]
10248 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10249 let mut config = Config::default();
10250 config.provider = "openai".to_string();
10251 config.providers.openai = Some(OpenAIConfig {
10252 api_key: "test".to_string(),
10253 api_key_encrypted: None,
10254 credential_ref: None,
10255 base_url: None,
10256 model: Some("legacy-gpt-4o".to_string()),
10257 fast_model: None,
10258 vision_model: None,
10259 reasoning_effort: None,
10260 responses_only_models: vec![],
10261 request_overrides: None,
10262 extra: Default::default(),
10263 api_key_from_env: false,
10264 });
10265 config.features.provider_model_ref = false;
10266 config.defaults = Some(DefaultsConfig {
10267 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10268 fast: None,
10269 task_summary: None,
10270 vision: None,
10271 memory_background: None,
10272 planning: None,
10273 search: None,
10274 code_review: None,
10275 sub_agent: None,
10276 subagent_models: Default::default(),
10277 });
10278
10279 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10280 }
10281
10282 #[test]
10283 #[allow(clippy::field_reassign_with_default)]
10285 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10286 let mut config = Config::default();
10287 config.provider = "openai".to_string();
10288 config.providers.openai = Some(OpenAIConfig {
10289 api_key: "test".to_string(),
10290 api_key_encrypted: None,
10291 credential_ref: None,
10292 base_url: None,
10293 model: Some("gpt-4o".to_string()),
10294 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10295 vision_model: None,
10296 reasoning_effort: None,
10297 responses_only_models: vec![],
10298 request_overrides: None,
10299 extra: Default::default(),
10300 api_key_from_env: false,
10301 });
10302 config.features.provider_model_ref = true;
10303 config.defaults = Some(DefaultsConfig {
10304 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10305 fast: Some(bamboo_domain::ProviderModelRef::new(
10306 "anthropic",
10307 "claude-3-5-haiku",
10308 )),
10309 task_summary: None,
10310 vision: None,
10311 memory_background: None,
10312 planning: None,
10313 search: None,
10314 code_review: None,
10315 sub_agent: None,
10316 subagent_models: Default::default(),
10317 });
10318
10319 assert_eq!(
10320 config.get_fast_model(),
10321 Some("claude-3-5-haiku".to_string())
10322 );
10323 }
10324
10325 #[test]
10326 #[allow(clippy::field_reassign_with_default)]
10328 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10329 let mut config = Config::default();
10330 config.provider = "openai".to_string();
10331 config.providers.openai = Some(OpenAIConfig {
10332 api_key: "test".to_string(),
10333 api_key_encrypted: None,
10334 credential_ref: None,
10335 base_url: None,
10336 model: Some("gpt-4o".to_string()),
10337 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10338 vision_model: None,
10339 reasoning_effort: None,
10340 responses_only_models: vec![],
10341 request_overrides: None,
10342 extra: Default::default(),
10343 api_key_from_env: false,
10344 });
10345 config.features.provider_model_ref = false;
10346 config.defaults = Some(DefaultsConfig {
10347 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10348 fast: Some(bamboo_domain::ProviderModelRef::new(
10349 "anthropic",
10350 "claude-3-5-haiku",
10351 )),
10352 task_summary: None,
10353 vision: None,
10354 memory_background: None,
10355 planning: None,
10356 search: None,
10357 code_review: None,
10358 sub_agent: None,
10359 subagent_models: Default::default(),
10360 });
10361
10362 assert_eq!(
10363 config.get_fast_model(),
10364 Some("legacy-gpt-4o-mini".to_string())
10365 );
10366 }
10367
10368 #[test]
10369 #[allow(clippy::field_reassign_with_default)]
10371 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10372 let mut config = Config::default();
10373 config.provider = "openai".to_string();
10374 config.features.provider_model_ref = true;
10375 config.defaults = Some(DefaultsConfig {
10376 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10377 fast: None,
10378 task_summary: None,
10379 vision: None,
10380 memory_background: None,
10381 planning: None,
10382 search: None,
10383 code_review: None,
10384 sub_agent: None,
10385 subagent_models: Default::default(),
10386 });
10387
10388 assert_eq!(
10389 config.get_fast_model(),
10390 Some("claude-3-7-sonnet".to_string())
10391 );
10392 }
10393
10394 #[test]
10395 #[allow(clippy::field_reassign_with_default)]
10397 fn get_memory_background_model_prefers_defaults_memory_background() {
10398 let mut config = Config::default();
10399 config.provider = "openai".to_string();
10400 config.providers.openai = Some(OpenAIConfig {
10401 api_key: "test".to_string(),
10402 api_key_encrypted: None,
10403 credential_ref: None,
10404 base_url: None,
10405 model: Some("gpt-4o".to_string()),
10406 fast_model: Some("gpt-4o-mini".to_string()),
10407 vision_model: None,
10408 reasoning_effort: None,
10409 responses_only_models: vec![],
10410 request_overrides: None,
10411 extra: Default::default(),
10412 api_key_from_env: false,
10413 });
10414 config.features.provider_model_ref = true;
10415 config.defaults = Some(DefaultsConfig {
10416 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10417 fast: Some(bamboo_domain::ProviderModelRef::new(
10418 "openai",
10419 "gpt-4o-mini",
10420 )),
10421 task_summary: None,
10422 vision: None,
10423 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10424 "anthropic",
10425 "claude-3-5-haiku",
10426 )),
10427 planning: None,
10428 search: None,
10429 code_review: None,
10430 sub_agent: None,
10431 subagent_models: Default::default(),
10432 });
10433
10434 assert_eq!(
10435 config.get_memory_background_model(),
10436 Some("claude-3-5-haiku".to_string())
10437 );
10438 }
10439
10440 #[test]
10441 #[allow(clippy::field_reassign_with_default)]
10443 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10444 let mut config = Config::default();
10445 config.provider = "openai".to_string();
10446 config.features.provider_model_ref = true;
10447 config.defaults = Some(DefaultsConfig {
10448 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10449 fast: Some(bamboo_domain::ProviderModelRef::new(
10450 "anthropic",
10451 "claude-3-5-haiku",
10452 )),
10453 task_summary: None,
10454 vision: None,
10455 memory_background: None,
10456 planning: None,
10457 search: None,
10458 code_review: None,
10459 sub_agent: None,
10460 subagent_models: Default::default(),
10461 });
10462
10463 assert_eq!(
10464 config.get_memory_background_model(),
10465 Some("claude-3-5-haiku".to_string())
10466 );
10467 }
10468
10469 #[test]
10470 #[allow(clippy::field_reassign_with_default)]
10472 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10473 let mut config = Config::default();
10474 config.provider = "openai".to_string();
10475 config.providers.openai = Some(OpenAIConfig {
10476 api_key: "test".to_string(),
10477 api_key_encrypted: None,
10478 credential_ref: None,
10479 base_url: None,
10480 model: Some("gpt-4o".to_string()),
10481 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10482 vision_model: None,
10483 reasoning_effort: None,
10484 responses_only_models: vec![],
10485 request_overrides: None,
10486 extra: Default::default(),
10487 api_key_from_env: false,
10488 });
10489 config.features.provider_model_ref = false;
10490 config.defaults = Some(DefaultsConfig {
10491 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10492 fast: Some(bamboo_domain::ProviderModelRef::new(
10493 "anthropic",
10494 "claude-3-5-haiku",
10495 )),
10496 task_summary: None,
10497 vision: None,
10498 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10499 "anthropic",
10500 "claude-3-5-haiku",
10501 )),
10502 planning: None,
10503 search: None,
10504 code_review: None,
10505 sub_agent: None,
10506 subagent_models: Default::default(),
10507 });
10508
10509 assert_eq!(
10510 config.get_memory_background_model(),
10511 Some("legacy-gpt-4o-mini".to_string())
10512 );
10513 }
10514
10515 #[test]
10522 fn is_host_trusted_requires_https_scheme() {
10523 let hosts = vec!["github.com/bigduu/".to_string()];
10524 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10525 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10526 }
10527
10528 #[test]
10529 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10530 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10532 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10533 let hosts = vec!["github.com/bigduu/".to_string()];
10535 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10536 }
10537
10538 #[test]
10539 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10540 let hosts = vec!["trusted.example.com".to_string()];
10541 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10542 assert!(!is_host_trusted(
10546 "https://trusted.example.com.evil.com/x",
10547 &hosts
10548 ));
10549 assert!(!is_host_trusted(
10550 "https://trusted.example.comevil.com/x",
10551 &hosts
10552 ));
10553 }
10554
10555 #[test]
10556 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10557 let hosts = vec!["github.com/bigduu/".to_string()];
10559 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10560 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10561 }
10562
10563 #[test]
10564 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10565 let hosts = vec!["example.com".to_string()];
10566 assert!(is_host_trusted("https://example.com/", &hosts));
10567 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10568 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10571 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10572 }
10573
10574 #[test]
10575 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10576 let hosts = vec!["github.com/bigduu/".to_string()];
10577 assert!(is_host_trusted(
10579 "https://someuser@github.com/bigduu/x",
10580 &hosts
10581 ));
10582 assert!(!is_host_trusted(
10585 "https://github.com@evil.com/bigduu/",
10586 &hosts
10587 ));
10588 }
10589
10590 #[test]
10591 fn is_host_trusted_ignores_an_explicit_port() {
10592 let hosts = vec!["github.com/bigduu/".to_string()];
10593 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10594 }
10595
10596 #[test]
10597 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10598 let hosts = vec!["github.com/bigduu/".to_string()];
10599 assert!(!is_host_trusted("not a url at all", &hosts));
10600 assert!(!is_host_trusted("", &hosts));
10601 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10603
10604 #[test]
10605 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10606 let hosts = vec!["github.com/bigduu/".to_string()];
10607 assert!(!is_host_trusted(
10610 "https://github.com/bigduu/../evil/x",
10611 &hosts
10612 ));
10613 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10615 }
10616
10617 #[test]
10618 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10619 let mut config = Config::default();
10620 config.plugin_trust.trusted_hosts = vec![
10621 " GitHub.com/BigDuu/ ".to_string(),
10622 "".to_string(),
10623 " ".to_string(),
10624 "Example.COM".to_string(),
10625 ];
10626 config.normalize_plugin_trust_settings();
10627 assert_eq!(
10628 config.plugin_trust.trusted_hosts,
10629 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10630 );
10631 }
10632
10633 #[test]
10639 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10640 let config = Config::default();
10642 assert_eq!(
10643 config.plugin_trust.enforcement,
10644 PluginTrustEnforcement::Strict
10645 );
10646 assert!(!config.plugin_trust.enforcement_is_off());
10647
10648 let json = serde_json::json!({
10654 "trusted_hosts": ["example.com"],
10655 "trusted_keys": [],
10656 });
10657 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10658 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10659 }
10660
10661 #[test]
10662 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10663 for raw in ["off", "OFF", "Off", " off "] {
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 Off: {e}"));
10668 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10669 assert!(trust.enforcement_is_off());
10670 }
10671 for raw in ["strict", "STRICT", " Strict "] {
10672 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10673 "enforcement": raw,
10674 }))
10675 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10676 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10677 }
10678
10679 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10680 "enforcement": "nonsense",
10681 }))
10682 .expect_err("an unrecognized string must be rejected, not silently default");
10683 assert!(err.to_string().contains("nonsense"));
10684 }
10685
10686 #[test]
10687 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10688 let trust: PluginTrustConfig =
10691 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10692 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10693
10694 let trust: PluginTrustConfig =
10695 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10696 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10697 }
10698
10699 #[test]
10700 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10701 let trust = PluginTrustConfig {
10706 enforcement: PluginTrustEnforcement::Off,
10707 ..PluginTrustConfig::default()
10708 };
10709 let json = serde_json::to_value(&trust).unwrap();
10710 assert_eq!(json["enforcement"], "off");
10711
10712 let trust = PluginTrustConfig {
10713 enforcement: PluginTrustEnforcement::Strict,
10714 ..PluginTrustConfig::default()
10715 };
10716 let json = serde_json::to_value(&trust).unwrap();
10717 assert_eq!(json["enforcement"], "strict");
10718 }
10719
10720 #[test]
10721 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10722 let mut config = Config::default();
10725 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10726 config.normalize_plugin_trust_settings();
10727 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10728 }
10729
10730 #[test]
10731 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10732 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10737 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10738 )));
10739 assert_eq!(
10740 config.plugin_trust.enforcement,
10741 PluginTrustEnforcement::Strict
10742 );
10743
10744 let outcome = crate::dot_path::apply_dot_path_set(
10745 &config,
10746 "plugin_trust.enforcement",
10747 crate::dot_path::parse_cli_value("off"),
10748 )
10749 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10750 assert_eq!(
10751 outcome.config.plugin_trust.enforcement,
10752 PluginTrustEnforcement::Off
10753 );
10754
10755 let outcome = crate::dot_path::apply_dot_path_set(
10757 &outcome.config,
10758 "plugin_trust.enforcement",
10759 crate::dot_path::parse_cli_value("strict"),
10760 )
10761 .expect("setting it back to strict should also round-trip");
10762 assert_eq!(
10763 outcome.config.plugin_trust.enforcement,
10764 PluginTrustEnforcement::Strict
10765 );
10766 }
10767}
10768
10769#[cfg(test)]
10770mod final_evidence_feature_tests {
10771 use super::FeatureFlags;
10772
10773 #[test]
10774 fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
10775 let existing: FeatureFlags =
10776 serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
10777 assert!(!existing.final_evidence_check);
10778 let enabled: FeatureFlags =
10779 serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
10780 let restored: FeatureFlags =
10781 serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
10782 assert!(restored.final_evidence_check);
10783 assert!(!FeatureFlags::default().final_evidence_check);
10784 }
10785}