1use anyhow::{Context, Result};
60use bamboo_domain::patrol_model_limits::{
61 MAX_EVIDENCE_BYTES, MAX_EVIDENCE_REFS, MAX_INPUT_TOKENS, MAX_OUTPUT_TOKENS,
62 MAX_PROVIDER_INPUT_TOKENS,
63};
64use bamboo_domain::poison::PoisonRecover;
65use serde::{Deserialize, Serialize};
66use serde_json::Value;
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::{Path, PathBuf};
69use std::sync::{OnceLock, RwLock};
70
71use crate::keyword_masking::KeywordMaskingConfig;
72use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
73use bamboo_domain::normalize_tool_ref;
74use bamboo_domain::ReasoningEffort;
75
76pub const MIN_STREAM_TIMEOUT_SECS: u64 = 1;
79pub const MAX_STREAM_TIMEOUT_SECS: u64 = 86_400;
83
84fn default_transport_idle_timeout_secs() -> u64 {
85 120
86}
87
88fn default_first_semantic_timeout_secs() -> u64 {
89 600
90}
91
92fn default_semantic_idle_timeout_secs() -> u64 {
93 600
94}
95
96fn deserialize_stream_timeout_secs<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
97where
98 D: serde::Deserializer<'de>,
99{
100 use serde::de::Error as _;
101
102 let value = u64::deserialize(deserializer)?;
103 if (MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
104 Ok(value)
105 } else {
106 Err(D::Error::custom(format!(
107 "stream timeout must be between {MIN_STREAM_TIMEOUT_SECS} and \
108 {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
109 )))
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(default)]
123pub struct StreamTimeoutConfig {
124 #[serde(
126 default = "default_transport_idle_timeout_secs",
127 deserialize_with = "deserialize_stream_timeout_secs"
128 )]
129 pub transport_idle_timeout_secs: u64,
130 #[serde(
132 default = "default_first_semantic_timeout_secs",
133 deserialize_with = "deserialize_stream_timeout_secs"
134 )]
135 pub first_semantic_timeout_secs: u64,
136 #[serde(
138 default = "default_semantic_idle_timeout_secs",
139 deserialize_with = "deserialize_stream_timeout_secs"
140 )]
141 pub semantic_idle_timeout_secs: u64,
142}
143
144impl Default for StreamTimeoutConfig {
145 fn default() -> Self {
146 Self {
147 transport_idle_timeout_secs: default_transport_idle_timeout_secs(),
148 first_semantic_timeout_secs: default_first_semantic_timeout_secs(),
149 semantic_idle_timeout_secs: default_semantic_idle_timeout_secs(),
150 }
151 }
152}
153
154impl StreamTimeoutConfig {
155 pub fn validate(&self) -> std::result::Result<(), String> {
158 for (name, value) in [
159 (
160 "transport_idle_timeout_secs",
161 self.transport_idle_timeout_secs,
162 ),
163 (
164 "first_semantic_timeout_secs",
165 self.first_semantic_timeout_secs,
166 ),
167 (
168 "semantic_idle_timeout_secs",
169 self.semantic_idle_timeout_secs,
170 ),
171 ] {
172 if !(MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
173 return Err(format!(
174 "{name} must be between {MIN_STREAM_TIMEOUT_SECS} and \
175 {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
176 ));
177 }
178 }
179 Ok(())
180 }
181}
182
183pub const MIN_LLM_RETRY_ATTEMPTS: u32 = 1;
187pub const MAX_LLM_RETRY_ATTEMPTS: u32 = 12;
188pub const MIN_LLM_RETRY_DELAY_MS: u64 = 1;
189pub const MAX_LLM_RETRY_DELAY_MS: u64 = 300_000;
190pub const MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 1_000;
191pub const MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 600_000;
192pub const MIN_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 1_000;
193pub const MAX_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 3_600_000;
194
195fn default_llm_retry_attempts() -> u32 {
196 8
200}
201
202fn default_llm_retry_attempt_timeout_ms() -> u64 {
203 180_000
204}
205
206fn default_llm_retry_total_budget_ms() -> u64 {
207 900_000
208}
209
210fn default_llm_retry_base_delay_ms() -> u64 {
211 500
212}
213
214fn default_llm_retry_max_delay_ms() -> u64 {
215 30_000
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(default)]
226pub struct LlmRetryConfig {
227 #[serde(default = "default_llm_retry_attempts")]
229 pub max_attempts: u32,
230 #[serde(default = "default_llm_retry_attempt_timeout_ms")]
232 pub attempt_timeout_ms: u64,
233 #[serde(default = "default_llm_retry_total_budget_ms")]
235 pub total_budget_ms: u64,
236 #[serde(default = "default_llm_retry_base_delay_ms")]
238 pub base_delay_ms: u64,
239 #[serde(default = "default_llm_retry_max_delay_ms")]
241 pub max_delay_ms: u64,
242}
243
244impl Default for LlmRetryConfig {
245 fn default() -> Self {
246 Self {
247 max_attempts: default_llm_retry_attempts(),
248 attempt_timeout_ms: default_llm_retry_attempt_timeout_ms(),
249 total_budget_ms: default_llm_retry_total_budget_ms(),
250 base_delay_ms: default_llm_retry_base_delay_ms(),
251 max_delay_ms: default_llm_retry_max_delay_ms(),
252 }
253 }
254}
255
256impl LlmRetryConfig {
257 pub fn validate(&self) -> std::result::Result<(), String> {
260 let checks = [
261 (
262 "max_attempts",
263 u64::from(self.max_attempts),
264 u64::from(MIN_LLM_RETRY_ATTEMPTS),
265 u64::from(MAX_LLM_RETRY_ATTEMPTS),
266 ),
267 (
268 "attempt_timeout_ms",
269 self.attempt_timeout_ms,
270 MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
271 MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
272 ),
273 (
274 "total_budget_ms",
275 self.total_budget_ms,
276 MIN_LLM_RETRY_TOTAL_BUDGET_MS,
277 MAX_LLM_RETRY_TOTAL_BUDGET_MS,
278 ),
279 (
280 "base_delay_ms",
281 self.base_delay_ms,
282 MIN_LLM_RETRY_DELAY_MS,
283 MAX_LLM_RETRY_DELAY_MS,
284 ),
285 (
286 "max_delay_ms",
287 self.max_delay_ms,
288 MIN_LLM_RETRY_DELAY_MS,
289 MAX_LLM_RETRY_DELAY_MS,
290 ),
291 ];
292 for (name, value, min, max) in checks {
293 if !(min..=max).contains(&value) {
294 return Err(format!(
295 "{name} must be between {min} and {max}, got {value}"
296 ));
297 }
298 }
299 if self.base_delay_ms > self.max_delay_ms {
300 return Err(format!(
301 "base_delay_ms ({}) must not exceed max_delay_ms ({})",
302 self.base_delay_ms, self.max_delay_ms
303 ));
304 }
305 Ok(())
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
315pub struct EnvVarEntry {
316 pub name: String,
318 #[serde(default, skip_serializing_if = "String::is_empty")]
321 pub value: String,
322 #[serde(default)]
324 pub secret: bool,
325 #[serde(default, skip_serializing)]
328 pub value_encrypted: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub credential_ref: Option<crate::CredentialRef>,
332 #[serde(default)]
334 pub configured: bool,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub description: Option<String>,
338}
339
340#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
345pub struct DefaultWorkAreaConfig {
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub path: Option<String>,
349}
350
351#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
353pub struct WorktreeSettings {
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub default_root: Option<String>,
356 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
357 pub project_roots: BTreeMap<String, String>,
358}
359
360impl WorktreeSettings {
361 pub fn is_empty(&self) -> bool {
362 self.default_root.is_none() && self.project_roots.is_empty()
363 }
364}
365
366#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
368pub struct AccessControlConfig {
369 #[serde(default)]
371 pub password_enabled: bool,
372 #[serde(default)]
377 pub repair_required: bool,
378 #[serde(default, skip_serializing)]
382 pub password_hash: Option<String>,
383 #[serde(default, skip_serializing)]
385 pub password_salt: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub password_credential_ref: Option<crate::CredentialRef>,
389 #[serde(default)]
391 pub password_configured: bool,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub updated_at: Option<String>,
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub devices: Vec<DeviceCredential>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409pub struct DeviceCredential {
410 pub device_id: String,
412 pub label: String,
414 #[serde(default, skip_serializing)]
416 pub token_hash: String,
417 #[serde(default, skip_serializing)]
419 pub token_salt: String,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub token_credential_ref: Option<crate::CredentialRef>,
423 #[serde(default)]
425 pub token_configured: bool,
426 pub created_at: String,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub last_used_at: Option<String>,
431 #[serde(default)]
434 pub revoked: bool,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
440pub struct MemoryConfig {
441 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub background_model: Option<String>,
445 #[serde(default = "default_summary_target_ratio")]
449 pub summary_target_ratio: f64,
450 #[serde(default = "default_summary_safe_window_percent")]
454 pub summary_safe_window_percent: u8,
455 #[serde(default = "default_true_auto_dream_enabled")]
460 pub auto_dream_enabled: bool,
461 #[serde(default = "default_auto_dream_interval_secs")]
465 pub auto_dream_interval_secs: u64,
466 #[serde(
468 default = "default_true_memory_project_prompt_injection",
469 alias = "memory_project_prompt_injection"
470 )]
471 pub project_prompt_injection: bool,
472 #[serde(
474 default = "default_true_memory_relevant_recall",
475 alias = "memory_relevant_recall"
476 )]
477 pub relevant_recall: bool,
478 #[serde(default, alias = "memory_relevant_recall_rerank")]
481 pub relevant_recall_rerank: bool,
482 #[serde(
484 default = "default_true_memory_project_first_dream",
485 alias = "memory_project_first_dream"
486 )]
487 pub project_first_dream: bool,
488 #[serde(
492 default = "default_true_memory_ledger_agenda",
493 alias = "memory_ledger_agenda_injection"
494 )]
495 pub ledger_agenda_injection: bool,
496 #[serde(default = "default_true_ledger_gardener_enabled")]
501 pub ledger_gardener_enabled: bool,
502 #[serde(default = "default_ledger_gardener_interval_secs")]
504 pub ledger_gardener_interval_secs: u64,
505 #[serde(default = "default_true_ledger_distillation_enabled")]
508 pub ledger_distillation_enabled: bool,
509 #[serde(default, alias = "memory_dream_refine_mode")]
516 pub dream_refine_mode: bool,
517 #[serde(
523 default = "default_true_gardener_enabled",
524 alias = "memory_gardener_enabled"
525 )]
526 pub gardener_enabled: bool,
527 #[serde(default = "default_gardener_interval_secs")]
530 pub gardener_interval_secs: u64,
531 #[serde(default = "default_gardener_volume_trigger")]
536 pub gardener_volume_trigger: usize,
537 #[serde(default = "default_gardener_max_splits_per_run")]
539 pub gardener_max_splits_per_run: usize,
540 #[serde(default = "default_gardener_min_sections")]
542 pub gardener_min_sections: usize,
543 #[serde(
548 default = "default_true_dedup_gardener_enabled",
549 alias = "memory_dedup_gardener_enabled"
550 )]
551 pub dedup_gardener_enabled: bool,
552 #[serde(default = "default_dedup_gardener_min_score")]
555 pub dedup_gardener_min_score: f64,
556 #[serde(default = "default_dedup_gardener_max_merges_per_run")]
558 pub dedup_gardener_max_merges_per_run: usize,
559 #[serde(default)]
567 pub memory_active_capacity: usize,
568 #[serde(default = "default_capacity_max_archivals_per_run")]
571 pub capacity_max_archivals_per_run: usize,
572 #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
580 pub granularity_freshness_gardener_enabled: bool,
581}
582
583impl Default for MemoryConfig {
584 fn default() -> Self {
585 Self {
586 background_model: None,
587 summary_target_ratio: default_summary_target_ratio(),
588 summary_safe_window_percent: default_summary_safe_window_percent(),
589 auto_dream_enabled: default_true_auto_dream_enabled(),
590 auto_dream_interval_secs: default_auto_dream_interval_secs(),
591 project_prompt_injection: default_true_memory_project_prompt_injection(),
592 relevant_recall: default_true_memory_relevant_recall(),
593 relevant_recall_rerank: false,
594 project_first_dream: default_true_memory_project_first_dream(),
595 ledger_agenda_injection: default_true_memory_ledger_agenda(),
596 ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
597 ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
598 ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
599 dream_refine_mode: false,
600 gardener_enabled: default_true_gardener_enabled(),
601 gardener_interval_secs: default_gardener_interval_secs(),
602 gardener_volume_trigger: default_gardener_volume_trigger(),
603 gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
604 gardener_min_sections: default_gardener_min_sections(),
605 dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
606 dedup_gardener_min_score: default_dedup_gardener_min_score(),
607 dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
608 memory_active_capacity: 0,
609 capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
610 granularity_freshness_gardener_enabled:
611 default_true_granularity_freshness_gardener_enabled(),
612 }
613 }
614}
615
616fn default_summary_target_ratio() -> f64 {
617 0.20
618}
619
620fn default_summary_safe_window_percent() -> u8 {
621 80
622}
623
624fn default_true_granularity_freshness_gardener_enabled() -> bool {
625 true
626}
627
628fn default_capacity_max_archivals_per_run() -> usize {
629 50
630}
631
632fn default_true_auto_dream_enabled() -> bool {
633 true
634}
635
636fn default_true_gardener_enabled() -> bool {
637 true
638}
639
640fn default_true_dedup_gardener_enabled() -> bool {
641 true
642}
643
644fn default_true_memory_ledger_agenda() -> bool {
645 true
646}
647
648fn default_true_ledger_gardener_enabled() -> bool {
649 true
650}
651
652fn default_ledger_gardener_interval_secs() -> u64 {
653 21_600
654}
655
656fn default_true_ledger_distillation_enabled() -> bool {
657 true
658}
659
660fn default_gardener_volume_trigger() -> usize {
664 25
665}
666
667fn default_gardener_interval_secs() -> u64 {
668 86_400
669}
670
671fn default_auto_dream_interval_secs() -> u64 {
672 60 * 30
673}
674
675fn default_gardener_max_splits_per_run() -> usize {
676 8
677}
678
679fn default_gardener_min_sections() -> usize {
680 5
681}
682
683fn default_dedup_gardener_min_score() -> f64 {
684 0.6
685}
686
687fn default_dedup_gardener_max_merges_per_run() -> usize {
688 8
689}
690
691fn default_true_memory_project_prompt_injection() -> bool {
692 true
693}
694
695fn default_true_memory_relevant_recall() -> bool {
696 true
697}
698
699fn default_true_memory_project_first_dream() -> bool {
700 true
701}
702
703#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
721pub struct RunBudgetConfig {
722 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub max_total_tokens: Option<u64>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub max_tool_calls: Option<u32>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub max_subagents: Option<u32>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub max_rounds: Option<u32>,
745}
746
747fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
750 match (config_default, request) {
751 (Some(a), Some(b)) => Some(a.min(b)),
752 (Some(a), None) => Some(a),
753 (None, Some(b)) => Some(b),
754 (None, None) => None,
755 }
756}
757
758impl RunBudgetConfig {
759 pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
774 let Some(over) = request_override else {
775 return *self;
776 };
777 Self {
778 max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
779 max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
780 max_subagents: min_limit(self.max_subagents, over.max_subagents),
781 max_rounds: min_limit(self.max_rounds, over.max_rounds),
782 }
783 }
784}
785
786#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum CodexAuthMode {
794 Inherit,
796 ApiKey,
798 Custom,
800 #[default]
802 Bamboo,
803}
804
805#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
807#[serde(rename_all = "snake_case")]
808pub enum CodexWireApi {
809 #[default]
810 Responses,
811}
812
813#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum CodexMode {
817 #[default]
819 Exec,
820 AppServer,
822}
823
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(rename_all = "kebab-case")]
827pub enum CodexSandbox {
828 ReadOnly,
829 WorkspaceWrite,
830 DangerFullAccess,
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "kebab-case")]
837pub enum CodexApprovalPolicy {
838 Never,
839 OnFailure,
840 OnRequest,
841}
842
843pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
845
846#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
857pub struct SubagentsConfig {
858 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub max_spawn_depth: Option<u32>,
862 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub max_concurrent: Option<usize>,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub worker_bin: Option<String>,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub worker_args: Option<Vec<String>>,
874 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub fabric_dir: Option<String>,
877 #[serde(default, skip_serializing_if = "Option::is_none")]
881 pub executor: Option<String>,
882 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub claude_code_binary: Option<String>,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub claude_code_model: Option<String>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub claude_code_permission_mode: Option<String>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub claude_code_inherit_user_config: Option<bool>,
902 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub claude_code_forward_env: Option<Vec<String>>,
909 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub codex_binary: Option<String>,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub codex_model: Option<String>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub codex_mode: Option<CodexMode>,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub codex_auth_mode: Option<CodexAuthMode>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub codex_base_url: Option<String>,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub codex_wire_api: Option<CodexWireApi>,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub codex_provider_key_ref: Option<crate::CredentialRef>,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub codex_forward_env: Option<Vec<String>>,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
942 pub codex_sandbox: Option<CodexSandbox>,
943 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub codex_approval_policy: Option<CodexApprovalPolicy>,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub codex_network_access: Option<bool>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub codex_allow_danger_bypass: Option<bool>,
954 #[serde(skip)]
961 pub broker: Option<BrokerClientConfig>,
962 #[serde(default, skip_serializing_if = "Vec::is_empty")]
968 pub remote_placements: Vec<RemoteActorPlacement>,
969 #[serde(default, skip_serializing_if = "Vec::is_empty")]
982 pub schedulable_placements: Vec<SchedulablePlacement>,
983 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002 pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
1003}
1004
1005impl SubagentsConfig {
1006 pub fn effective_max_spawn_depth(&self) -> u32 {
1007 self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
1008 }
1009}
1010
1011#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1014pub struct McpRoleAllowlistEntry {
1015 pub role: String,
1023 #[serde(default)]
1031 pub tools: Vec<String>,
1032}
1033
1034#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(deny_unknown_fields)]
1041pub struct OperatorPlacementRequirements {
1042 pub trust_zone: String,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1044 pub workspace_label: Option<String>,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub network_zone: Option<String>,
1047 #[serde(default)]
1048 pub require_network_isolation: bool,
1049 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1052 pub required_tools: std::collections::BTreeSet<String>,
1053}
1054
1055#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1058#[serde(deny_unknown_fields)]
1059pub struct SchedulableBrokerParent {
1060 pub parent_mailbox: String,
1061 pub parent_role: String,
1062}
1063
1064#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1068pub struct SchedulablePlacement {
1069 pub role: String,
1072 pub pool: String,
1074 #[serde(default, skip_serializing_if = "String::is_empty")]
1076 pub registry_url: String,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub token_env: Option<String>,
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub ca_cert_file: Option<String>,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub broker_parent: Option<SchedulableBrokerParent>,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1091 pub placement_requirements: Option<OperatorPlacementRequirements>,
1092}
1093
1094#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1105pub struct RemoteActorPlacement {
1106 pub role: String,
1109 pub endpoint: String,
1112 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub token_env: Option<String>,
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub ca_cert_file: Option<String>,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub broker_peer: Option<RemoteBrokerPeer>,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub placement_requirements: Option<OperatorPlacementRequirements>,
1126}
1127
1128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct RemoteBrokerPeer {
1132 pub parent_mailbox: String,
1133 pub worker_mailbox: String,
1134 pub parent_role: Option<String>,
1135 pub worker_role: Option<String>,
1136}
1137impl RemoteBrokerPeer {
1138 pub fn valid(&self) -> bool {
1139 fn identifier(s: &str, mailbox: bool) -> bool {
1140 !s.is_empty()
1141 && s.len() <= 256
1142 && s != "."
1143 && s != ".."
1144 && s.bytes()
1145 .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1146 && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1147 }
1148 identifier(&self.parent_mailbox, true)
1149 && identifier(&self.worker_mailbox, true)
1150 && self.parent_mailbox != self.worker_mailbox
1151 && self
1152 .parent_role
1153 .as_deref()
1154 .is_none_or(|s| identifier(s, false))
1155 && self
1156 .worker_role
1157 .as_deref()
1158 .is_some_and(|s| identifier(s, false))
1159 }
1160}
1161
1162#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1164pub struct BrokerClientConfig {
1165 pub endpoint: String,
1167 #[serde(default, skip_serializing)]
1173 pub token: String,
1174 #[serde(default, skip_serializing)]
1176 pub token_encrypted: Option<String>,
1177 #[serde(default, skip_serializing_if = "Option::is_none")]
1179 pub credential_ref: Option<crate::CredentialRef>,
1180 #[serde(default)]
1183 pub configured: bool,
1184}
1185
1186impl std::fmt::Debug for BrokerClientConfig {
1187 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1188 formatter
1189 .debug_struct("BrokerClientConfig")
1190 .field("endpoint", &self.endpoint)
1191 .field("credential_ref", &self.credential_ref)
1192 .field("configured", &self.configured)
1193 .finish_non_exhaustive()
1194 }
1195}
1196
1197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1199pub struct DesktopChannelConfig {
1200 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub enabled: Option<bool>,
1208}
1209
1210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1212pub struct NtfyChannelConfig {
1213 #[serde(default)]
1214 pub enabled: bool,
1215 #[serde(default = "default_ntfy_base_url")]
1217 pub base_url: String,
1218 #[serde(default)]
1221 pub topic: String,
1222 #[serde(default, skip_serializing)]
1228 pub token: Option<String>,
1229 #[serde(default, skip_serializing)]
1231 pub token_encrypted: Option<String>,
1232 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub credential_ref: Option<crate::CredentialRef>,
1235 #[serde(default)]
1237 pub configured: bool,
1238}
1239
1240impl Default for NtfyChannelConfig {
1241 fn default() -> Self {
1242 Self {
1243 enabled: false,
1244 base_url: default_ntfy_base_url(),
1245 topic: String::new(),
1246 token: None,
1247 token_encrypted: None,
1248 credential_ref: None,
1249 configured: false,
1250 }
1251 }
1252}
1253
1254fn default_ntfy_base_url() -> String {
1255 "https://ntfy.sh".to_string()
1256}
1257
1258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1260pub struct BarkChannelConfig {
1261 #[serde(default)]
1262 pub enabled: bool,
1263 #[serde(default = "default_bark_base_url")]
1265 pub base_url: String,
1266 #[serde(default, skip_serializing)]
1271 pub device_key: Option<String>,
1272 #[serde(default, skip_serializing)]
1274 pub device_key_encrypted: Option<String>,
1275 #[serde(default, skip_serializing_if = "Option::is_none")]
1277 pub credential_ref: Option<crate::CredentialRef>,
1278 #[serde(default)]
1280 pub configured: bool,
1281}
1282
1283impl Default for BarkChannelConfig {
1284 fn default() -> Self {
1285 Self {
1286 enabled: false,
1287 base_url: default_bark_base_url(),
1288 device_key: None,
1289 device_key_encrypted: None,
1290 credential_ref: None,
1291 configured: false,
1292 }
1293 }
1294}
1295
1296fn default_bark_base_url() -> String {
1297 "https://api.day.app".to_string()
1298}
1299
1300#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1305pub struct NotificationsConfig {
1306 #[serde(default)]
1307 pub desktop: DesktopChannelConfig,
1308 #[serde(default)]
1309 pub ntfy: NtfyChannelConfig,
1310 #[serde(default)]
1311 pub bark: BarkChannelConfig,
1312}
1313
1314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1318pub struct ConnectPlatformConfig {
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1333 pub id: Option<String>,
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1338 pub project_id: Option<bamboo_domain::ProjectId>,
1339 #[serde(rename = "type")]
1343 pub platform_type: String,
1344 #[serde(default, skip_serializing)]
1350 pub token: Option<String>,
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1353 pub token_encrypted: Option<String>,
1354 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 pub token_credential_ref: Option<crate::CredentialRef>,
1357 #[serde(default)]
1359 pub token_configured: bool,
1360 #[serde(default, skip_serializing_if = "Option::is_none")]
1363 pub app_id: Option<String>,
1364 #[serde(default, skip_serializing)]
1370 pub app_secret: Option<String>,
1371 #[serde(default, skip_serializing_if = "Option::is_none")]
1373 pub app_secret_encrypted: Option<String>,
1374 #[serde(default, skip_serializing_if = "Option::is_none")]
1376 pub app_secret_credential_ref: Option<crate::CredentialRef>,
1377 #[serde(default)]
1379 pub app_secret_configured: bool,
1380 #[serde(default, skip_serializing_if = "Option::is_none")]
1386 pub domain: Option<String>,
1387 #[serde(default)]
1392 pub allow_from: Vec<String>,
1393 #[serde(default)]
1397 pub admin_from: Vec<String>,
1398}
1399
1400#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1404pub struct ConnectConfig {
1405 #[serde(default)]
1406 pub platforms: Vec<ConnectPlatformConfig>,
1407}
1408
1409fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1410 config.platforms.is_empty()
1411}
1412
1413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1420pub struct TrustedKey {
1421 pub label: String,
1423 pub algorithm: String,
1425 pub public_key: String,
1427}
1428
1429pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1431 "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1432
1433pub(crate) fn default_trusted_hosts() -> Vec<String> {
1436 vec!["github.com/bigduu/".to_string()]
1437}
1438
1439pub use crate::plugin_trust::{PluginTrustConfig, PublisherMode};
1440
1441#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1454#[serde(rename_all = "snake_case")]
1455pub enum PluginTrustEnforcement {
1456 #[default]
1461 Strict,
1462 Off,
1466}
1467
1468impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1469 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1470 where
1471 D: serde::Deserializer<'de>,
1472 {
1473 #[derive(Deserialize)]
1474 #[serde(untagged)]
1475 enum Repr {
1476 Bool(bool),
1477 Str(String),
1478 }
1479 match Repr::deserialize(deserializer)? {
1480 Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1481 Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1482 Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1483 "strict" => Ok(PluginTrustEnforcement::Strict),
1484 "off" => Ok(PluginTrustEnforcement::Off),
1485 other => Err(serde::de::Error::custom(format!(
1486 "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1487 \"off\""
1488 ))),
1489 },
1490 }
1491 }
1492}
1493
1494struct TrustedHostEntry<'a> {
1499 host: &'a str,
1500 path_prefix: &'a str,
1501}
1502
1503fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1507 match entry.find('/') {
1508 Some(index) => TrustedHostEntry {
1509 host: &entry[..index],
1510 path_prefix: &entry[index..],
1511 },
1512 None => TrustedHostEntry {
1513 host: entry,
1514 path_prefix: "",
1515 },
1516 }
1517}
1518
1519fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1531 if prefix.is_empty() || path == prefix {
1532 return true;
1533 }
1534 if prefix.ends_with('/') {
1535 return path.starts_with(prefix);
1536 }
1537 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1538}
1539
1540pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1564 let Ok(parsed) = url::Url::parse(url) else {
1565 return false;
1566 };
1567 if parsed.scheme() != "https" {
1568 return false;
1569 }
1570 let Some(host) = parsed.host_str() else {
1571 return false;
1572 };
1573 let host = host.to_ascii_lowercase();
1574 let path = parsed.path();
1575
1576 trusted_hosts.iter().any(|raw_entry| {
1577 let entry = raw_entry.trim().to_ascii_lowercase();
1578 let parsed_entry = parse_trusted_host_entry(&entry);
1579 host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1580 })
1581}
1582
1583#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1592#[serde(rename_all = "snake_case")]
1593pub enum ContextManagementStrategy {
1594 #[default]
1595 Summary,
1596 MessageSummary,
1597 RetrievalWindow,
1598}
1599
1600#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1603#[serde(rename_all = "snake_case")]
1604pub enum ContextManagementFallbackStrategy {
1605 #[default]
1606 None,
1607 Summary,
1608}
1609
1610fn default_retrieval_window_min_recent_user_turns() -> usize {
1611 3
1612}
1613
1614fn default_context_trigger_usage_ratio() -> f64 {
1615 0.80
1616}
1617
1618fn default_context_target_usage_ratio() -> f64 {
1619 0.25
1620}
1621
1622fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1623 0.80
1624}
1625
1626fn default_retrieval_window_target_usage_ratio() -> f64 {
1627 0.60
1628}
1629
1630fn default_history_tool_required() -> bool {
1631 true
1632}
1633
1634#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1636#[serde(default)]
1637pub struct RetrievalWindowContextConfig {
1638 #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1639 pub min_recent_user_turns: usize,
1640 #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1641 pub trigger_usage_ratio: f64,
1642 #[serde(default = "default_retrieval_window_target_usage_ratio")]
1643 pub target_usage_ratio: f64,
1644 #[serde(default = "default_history_tool_required")]
1645 pub history_tool_required: bool,
1646 #[serde(default)]
1647 pub fallback_strategy: ContextManagementFallbackStrategy,
1648}
1649
1650impl Default for RetrievalWindowContextConfig {
1651 fn default() -> Self {
1652 Self {
1653 min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1654 trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1655 target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1656 history_tool_required: default_history_tool_required(),
1657 fallback_strategy: ContextManagementFallbackStrategy::None,
1658 }
1659 }
1660}
1661
1662#[derive(Debug, Clone, Serialize, PartialEq)]
1664pub struct ContextManagementConfig {
1665 pub strategy: ContextManagementStrategy,
1666 pub trigger_usage_ratio: f64,
1669 pub target_usage_ratio: f64,
1671 pub retrieval_window: RetrievalWindowContextConfig,
1672}
1673
1674impl Default for ContextManagementConfig {
1675 fn default() -> Self {
1676 Self {
1677 strategy: ContextManagementStrategy::Summary,
1678 trigger_usage_ratio: default_context_trigger_usage_ratio(),
1679 target_usage_ratio: default_context_target_usage_ratio(),
1680 retrieval_window: RetrievalWindowContextConfig::default(),
1681 }
1682 }
1683}
1684
1685impl ContextManagementConfig {
1686 pub fn is_default(&self) -> bool {
1687 *self == Self::default()
1688 }
1689
1690 pub fn validate(&self) -> std::result::Result<(), String> {
1691 if !self.target_usage_ratio.is_finite()
1692 || !self.trigger_usage_ratio.is_finite()
1693 || self.target_usage_ratio < 0.01
1694 || self.target_usage_ratio >= self.trigger_usage_ratio
1695 || self.trigger_usage_ratio > 1.0
1696 || (self.target_usage_ratio * 100.0).floor()
1697 >= (self.trigger_usage_ratio * 100.0).floor()
1698 {
1699 return Err(
1700 "context_management ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1 with distinct whole-percent boundaries"
1701 .to_string(),
1702 );
1703 }
1704 let policy = &self.retrieval_window;
1705 if policy.min_recent_user_turns == 0 {
1706 return Err(
1707 "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1708 .to_string(),
1709 );
1710 }
1711 if !policy.target_usage_ratio.is_finite()
1712 || !policy.trigger_usage_ratio.is_finite()
1713 || policy.target_usage_ratio < 0.01
1714 || policy.target_usage_ratio >= policy.trigger_usage_ratio
1715 || policy.trigger_usage_ratio > 1.0
1716 {
1717 return Err(
1718 "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1719 .to_string(),
1720 );
1721 }
1722 if self.strategy == ContextManagementStrategy::RetrievalWindow
1723 && !policy.history_tool_required
1724 {
1725 return Err(
1726 "retrieval_window requires history_tool_required=true in this release".to_string(),
1727 );
1728 }
1729 Ok(())
1730 }
1731
1732 pub fn apply_compression_policy(
1736 &self,
1737 mut budget: bamboo_domain::TokenBudget,
1738 ) -> bamboo_domain::TokenBudget {
1739 budget.compression_trigger_percent = (self.trigger_usage_ratio * 100.0).floor() as u8;
1740 budget.compression_target_percent = (self.target_usage_ratio * 100.0).floor() as u8;
1741 budget
1742 }
1743
1744 pub fn retrieval_target_usage_percent(&self) -> u8 {
1747 ((self.target_usage_ratio * 100.0).floor() as u8).max(1)
1748 }
1749}
1750
1751impl<'de> Deserialize<'de> for ContextManagementConfig {
1752 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1753 where
1754 D: serde::Deserializer<'de>,
1755 {
1756 #[derive(Default, Deserialize)]
1757 #[serde(default)]
1758 struct Wire {
1759 strategy: ContextManagementStrategy,
1760 trigger_usage_ratio: Option<f64>,
1761 target_usage_ratio: Option<f64>,
1762 retrieval_window: Option<RetrievalWindowContextConfig>,
1763 }
1764
1765 let wire = Wire::deserialize(deserializer)?;
1766 let retrieval_window = wire.retrieval_window.unwrap_or_default();
1767 let config = Self {
1771 strategy: wire.strategy,
1772 trigger_usage_ratio: wire.trigger_usage_ratio.unwrap_or_else(|| {
1773 default_context_trigger_usage_ratio().min(retrieval_window.trigger_usage_ratio)
1774 }),
1775 target_usage_ratio: wire.target_usage_ratio.unwrap_or_else(|| {
1776 default_context_target_usage_ratio().min(retrieval_window.target_usage_ratio)
1777 }),
1778 retrieval_window,
1779 };
1780 config.validate().map_err(serde::de::Error::custom)?;
1781 Ok(config)
1782 }
1783}
1784
1785#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1787#[serde(rename_all = "snake_case")]
1788pub enum PatrolModelProtocol {
1789 LlamaCppRawV1,
1790 RegisteredProviderV1,
1791}
1792
1793#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1795#[serde(deny_unknown_fields)]
1796pub struct PatrolModelConfig {
1797 pub provider: String,
1798 pub model: String,
1799 pub protocol: PatrolModelProtocol,
1800 #[serde(default, skip_serializing_if = "Option::is_none")]
1803 pub endpoint: Option<std::net::SocketAddr>,
1804 pub max_input_tokens: std::num::NonZeroU32,
1805 pub max_output_tokens: std::num::NonZeroU32,
1806 pub max_evidence_bytes: std::num::NonZeroUsize,
1807 pub max_evidence_refs: std::num::NonZeroUsize,
1808}
1809
1810impl PatrolModelConfig {
1811 pub fn validate(&self) -> Result<(), String> {
1812 if self.provider.is_empty()
1813 || self.provider.len() > 256
1814 || self
1815 .provider
1816 .chars()
1817 .any(|ch| ch.is_whitespace() || ch.is_control())
1818 {
1819 return Err("patrol_model requires an explicit provider identifier".into());
1820 }
1821 let input_limit = match self.protocol {
1822 PatrolModelProtocol::LlamaCppRawV1 => MAX_INPUT_TOKENS,
1823 PatrolModelProtocol::RegisteredProviderV1 => MAX_PROVIDER_INPUT_TOKENS,
1824 };
1825 if self.max_input_tokens.get() > input_limit
1826 || self.max_output_tokens.get() > MAX_OUTPUT_TOKENS
1827 || self.max_evidence_bytes.get() > MAX_EVIDENCE_BYTES
1828 || self.max_evidence_refs.get() > MAX_EVIDENCE_REFS
1829 {
1830 return Err(format!(
1831 "patrol_model limits must be at most {input_limit} input tokens, \
1832 {MAX_OUTPUT_TOKENS} output tokens, {MAX_EVIDENCE_BYTES} evidence bytes, \
1833 and {MAX_EVIDENCE_REFS} evidence references"
1834 ));
1835 }
1836 if self
1837 .endpoint
1838 .is_some_and(|endpoint| !endpoint.ip().is_loopback() || endpoint.port() == 0)
1839 {
1840 return Err(
1841 "patrol_model endpoint requires a numeric loopback address and port".into(),
1842 );
1843 }
1844 match self.protocol {
1845 PatrolModelProtocol::LlamaCppRawV1 if self.model == "gpt-oss-20b" => Ok(()),
1846 PatrolModelProtocol::LlamaCppRawV1 => {
1847 Err("llama_cpp_raw_v1 requires the gpt-oss-20b patrol model".into())
1848 }
1849 PatrolModelProtocol::RegisteredProviderV1
1850 if self.endpoint.is_none()
1851 && !self.model.is_empty()
1852 && self.model.len() <= 256
1853 && !self.model.chars().any(|ch| ch.is_whitespace() || ch.is_control()) =>
1854 {
1855 Ok(())
1856 }
1857 PatrolModelProtocol::RegisteredProviderV1 => Err(
1858 "registered_provider_v1 requires a named model and uses the configured provider endpoint".into(),
1859 ),
1860 }
1861 }
1862}
1863
1864#[derive(Debug, Clone, Serialize, Deserialize)]
1869#[doc(hidden)]
1870pub struct ConfigValues {
1871 #[serde(default)]
1873 pub http_proxy: String,
1874 #[serde(default)]
1876 pub https_proxy: String,
1877 #[serde(skip_serializing)]
1881 pub proxy_auth: Option<ProxyAuth>,
1882 #[serde(default, skip_serializing_if = "Option::is_none")]
1884 pub proxy_auth_encrypted: Option<String>,
1885 #[serde(default, skip_serializing_if = "Option::is_none")]
1887 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1888 #[serde(default)]
1890 pub headless_auth: bool,
1891
1892 #[serde(default = "default_provider")]
1894 pub provider: String,
1895
1896 #[serde(default, skip_serializing_if = "Option::is_none")]
1898 pub defaults: Option<DefaultsConfig>,
1899
1900 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1907 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1908
1909 #[serde(default, skip_serializing_if = "Option::is_none")]
1913 pub default_provider_instance: Option<String>,
1914
1915 #[serde(default)]
1917 pub server: ServerConfig,
1918
1919 #[serde(default)]
1923 pub keyword_masking: KeywordMaskingConfig,
1924
1925 #[serde(default)]
1929 pub anthropic_model_mapping: AnthropicModelMapping,
1930
1931 #[serde(default)]
1935 pub gemini_model_mapping: GeminiModelMapping,
1936
1937 #[serde(default, skip_serializing_if = "Option::is_none")]
1939 pub patrol_model: Option<PatrolModelConfig>,
1940
1941 #[serde(default)]
1946 pub hooks: HooksConfig,
1947
1948 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1954 pub lifecycle_hooks: LifecycleHooksConfig,
1955
1956 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1960 pub tools: ToolsConfig,
1961
1962 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1967 pub skills: SkillsConfig,
1968
1969 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1973 pub env_vars: Vec<EnvVarEntry>,
1974
1975 #[serde(default, skip_serializing_if = "Option::is_none")]
1977 pub default_work_area: Option<DefaultWorkAreaConfig>,
1978
1979 #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1980 pub worktrees: WorktreeSettings,
1981
1982 #[serde(default, skip_serializing_if = "Option::is_none")]
1984 pub access_control: Option<AccessControlConfig>,
1985
1986 #[serde(default)]
1988 pub features: FeatureFlags,
1989
1990 #[serde(default)]
1995 pub run_budget: RunBudgetConfig,
1996
1997 #[serde(default)]
1999 pub stream_timeout: StreamTimeoutConfig,
2000
2001 #[serde(default, skip_serializing_if = "Option::is_none")]
2010 pub llm_retry: Option<LlmRetryConfig>,
2011
2012 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2014 pub context_management: ContextManagementConfig,
2015
2016 #[serde(
2020 default,
2021 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2022 )]
2023 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2024
2025 #[serde(default, rename = "mcpServers", alias = "mcp")]
2031 pub mcp: bamboo_domain::mcp_config::McpConfig,
2032
2033 #[serde(default)]
2037 pub notifications: NotificationsConfig,
2038
2039 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2044 pub connect: ConnectConfig,
2045
2046 #[serde(default)]
2050 pub plugin_trust: PluginTrustConfig,
2051
2052 #[serde(default, flatten)]
2058 pub extra: BTreeMap<String, Value>,
2059}
2060
2061impl Default for ConfigValues {
2062 fn default() -> Self {
2063 Self {
2064 http_proxy: String::new(),
2065 https_proxy: String::new(),
2066 proxy_auth: None,
2067 proxy_auth_encrypted: None,
2068 proxy_auth_credential_ref: None,
2069 headless_auth: false,
2070 run_budget: RunBudgetConfig::default(),
2071 stream_timeout: StreamTimeoutConfig::default(),
2072 llm_retry: None,
2073 context_management: ContextManagementConfig::default(),
2074 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2075 provider: default_provider(),
2076 provider_instances: HashMap::new(),
2077 default_provider_instance: None,
2078 server: ServerConfig::default(),
2079 keyword_masking: KeywordMaskingConfig::default(),
2080 anthropic_model_mapping: AnthropicModelMapping::default(),
2081 gemini_model_mapping: GeminiModelMapping::default(),
2082 patrol_model: None,
2083 hooks: HooksConfig::default(),
2084 lifecycle_hooks: LifecycleHooksConfig::default(),
2085 tools: ToolsConfig::default(),
2086 skills: SkillsConfig::default(),
2087 env_vars: Vec::new(),
2088 default_work_area: None,
2089 worktrees: WorktreeSettings::default(),
2090 access_control: None,
2091 features: FeatureFlags::default(),
2092 defaults: None,
2093 mcp: bamboo_domain::mcp_config::McpConfig::default(),
2094 notifications: NotificationsConfig::default(),
2095 connect: ConnectConfig::default(),
2096 plugin_trust: PluginTrustConfig::default(),
2097 extra: BTreeMap::new(),
2098 }
2099 }
2100}
2101
2102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2105struct NetworkConfigSection {
2106 #[serde(default)]
2107 http_proxy: String,
2108 #[serde(default)]
2109 https_proxy: String,
2110 #[serde(skip_serializing)]
2111 proxy_auth: Option<ProxyAuth>,
2112 #[serde(default, skip_serializing_if = "Option::is_none")]
2113 proxy_auth_encrypted: Option<String>,
2114 #[serde(default, skip_serializing_if = "Option::is_none")]
2115 proxy_auth_credential_ref: Option<crate::CredentialRef>,
2116 #[serde(default)]
2117 headless_auth: bool,
2118 #[serde(default)]
2119 server: ServerConfig,
2120}
2121
2122#[derive(Debug, Clone, Serialize, Deserialize)]
2123struct ProviderRoutingConfigSection {
2124 #[serde(default = "default_provider")]
2125 provider: String,
2126 #[serde(default, skip_serializing_if = "Option::is_none")]
2127 defaults: Option<DefaultsConfig>,
2128 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2129 provider_instances: HashMap<String, ProviderInstanceConfig>,
2130 #[serde(default, skip_serializing_if = "Option::is_none")]
2131 default_provider_instance: Option<String>,
2132}
2133
2134impl Default for ProviderRoutingConfigSection {
2135 fn default() -> Self {
2136 Self {
2137 provider: default_provider(),
2138 defaults: None,
2139 provider_instances: HashMap::new(),
2140 default_provider_instance: None,
2141 }
2142 }
2143}
2144
2145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2146struct ModelBehaviorConfigSection {
2147 #[serde(default)]
2148 keyword_masking: KeywordMaskingConfig,
2149 #[serde(default)]
2150 anthropic_model_mapping: AnthropicModelMapping,
2151 #[serde(default)]
2152 gemini_model_mapping: GeminiModelMapping,
2153 #[serde(default, skip_serializing_if = "Option::is_none")]
2154 patrol_model: Option<PatrolModelConfig>,
2155 #[serde(default)]
2156 hooks: HooksConfig,
2157 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
2158 lifecycle_hooks: LifecycleHooksConfig,
2159}
2160
2161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2162struct ToolingConfigSection {
2163 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2164 tools: ToolsConfig,
2165 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2166 skills: SkillsConfig,
2167 #[serde(default, rename = "mcpServers", alias = "mcp")]
2168 mcp: bamboo_domain::mcp_config::McpConfig,
2169}
2170
2171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2172struct WorkspaceConfigSection {
2173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2174 env_vars: Vec<EnvVarEntry>,
2175 #[serde(default, skip_serializing_if = "Option::is_none")]
2176 default_work_area: Option<DefaultWorkAreaConfig>,
2177 #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2178 worktrees: WorktreeSettings,
2179 #[serde(default, skip_serializing_if = "Option::is_none")]
2180 access_control: Option<AccessControlConfig>,
2181}
2182
2183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2184struct ExecutionConfigSection {
2185 #[serde(default)]
2186 features: FeatureFlags,
2187 #[serde(default)]
2188 run_budget: RunBudgetConfig,
2189 #[serde(default)]
2190 stream_timeout: StreamTimeoutConfig,
2191 #[serde(default, skip_serializing_if = "Option::is_none")]
2192 llm_retry: Option<LlmRetryConfig>,
2193 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2194 context_management: ContextManagementConfig,
2195 #[serde(
2196 default,
2197 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2198 )]
2199 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2200}
2201
2202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2203struct IntegrationConfigSection {
2204 #[serde(default)]
2205 notifications: NotificationsConfig,
2206 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2207 connect: ConnectConfig,
2208}
2209
2210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2211struct PluginSecurityConfigSection {
2212 #[serde(default)]
2213 plugin_trust: PluginTrustConfig,
2214}
2215
2216macro_rules! count_fields {
2219 ($($field:ident),* $(,)?) => {
2220 <[()]>::len(&[$(count_fields!(@one $field)),*])
2221 };
2222 (@one $field:ident) => { () };
2223}
2224
2225macro_rules! define_counted_struct {
2226 (
2227 $(#[$struct_meta:meta])*
2228 $visibility:vis struct $name:ident {
2229 $(
2230 $(#[$field_meta:meta])*
2231 $field_visibility:vis $field:ident: $field_type:ty
2232 ),* $(,)?
2233 }
2234 count $count_visibility:vis $count_name:ident;
2235 ) => {
2236 $(#[$struct_meta])*
2237 $visibility struct $name {
2238 $(
2239 $(#[$field_meta])*
2240 $field_visibility $field: $field_type,
2241 )*
2242 }
2243
2244 $count_visibility const $count_name: usize = count_fields!($($field),*);
2245 };
2246}
2247
2248define_counted_struct! {
2249 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2255 struct ConfigRoot {
2256 #[serde(flatten)]
2257 network: NetworkConfigSection,
2258 #[serde(flatten)]
2259 provider_routing: ProviderRoutingConfigSection,
2260 #[serde(flatten)]
2261 model_behavior: ModelBehaviorConfigSection,
2262 #[serde(flatten)]
2263 tooling: ToolingConfigSection,
2264 #[serde(flatten)]
2265 workspace: WorkspaceConfigSection,
2266 #[serde(flatten)]
2267 execution: ExecutionConfigSection,
2268 #[serde(flatten)]
2269 integrations: IntegrationConfigSection,
2270 #[serde(flatten)]
2271 plugin_security: PluginSecurityConfigSection,
2272 #[serde(default, flatten)]
2273 extra: BTreeMap<String, Value>,
2274 }
2275 count pub PERSISTED_ROOT_FIELD_COUNT;
2276}
2277
2278impl From<ConfigValues> for ConfigRoot {
2279 fn from(values: ConfigValues) -> Self {
2280 let ConfigValues {
2283 http_proxy,
2284 https_proxy,
2285 proxy_auth,
2286 proxy_auth_encrypted,
2287 proxy_auth_credential_ref,
2288 headless_auth,
2289 provider,
2290 defaults,
2291 provider_instances,
2292 default_provider_instance,
2293 server,
2294 keyword_masking,
2295 anthropic_model_mapping,
2296 gemini_model_mapping,
2297 patrol_model,
2298 hooks,
2299 lifecycle_hooks,
2300 tools,
2301 skills,
2302 env_vars,
2303 default_work_area,
2304 worktrees,
2305 access_control,
2306 features,
2307 run_budget,
2308 stream_timeout,
2309 llm_retry,
2310 context_management,
2311 cluster_fabric,
2312 mcp,
2313 notifications,
2314 connect,
2315 plugin_trust,
2316 extra,
2317 } = values;
2318
2319 Self {
2320 network: NetworkConfigSection {
2321 http_proxy,
2322 https_proxy,
2323 proxy_auth,
2324 proxy_auth_encrypted,
2325 proxy_auth_credential_ref,
2326 headless_auth,
2327 server,
2328 },
2329 provider_routing: ProviderRoutingConfigSection {
2330 provider,
2331 defaults,
2332 provider_instances,
2333 default_provider_instance,
2334 },
2335 model_behavior: ModelBehaviorConfigSection {
2336 keyword_masking,
2337 anthropic_model_mapping,
2338 gemini_model_mapping,
2339 patrol_model,
2340 hooks,
2341 lifecycle_hooks,
2342 },
2343 tooling: ToolingConfigSection { tools, skills, mcp },
2344 workspace: WorkspaceConfigSection {
2345 env_vars,
2346 default_work_area,
2347 worktrees,
2348 access_control,
2349 },
2350 execution: ExecutionConfigSection {
2351 features,
2352 run_budget,
2353 stream_timeout,
2354 llm_retry,
2355 context_management,
2356 cluster_fabric,
2357 },
2358 integrations: IntegrationConfigSection {
2359 notifications,
2360 connect,
2361 },
2362 plugin_security: PluginSecurityConfigSection { plugin_trust },
2363 extra,
2364 }
2365 }
2366}
2367
2368impl From<ConfigRoot> for ConfigValues {
2369 fn from(root: ConfigRoot) -> Self {
2370 let ConfigRoot {
2373 network,
2374 provider_routing,
2375 model_behavior,
2376 tooling,
2377 workspace,
2378 execution,
2379 integrations,
2380 plugin_security,
2381 extra,
2382 } = root;
2383 let NetworkConfigSection {
2384 http_proxy,
2385 https_proxy,
2386 proxy_auth,
2387 proxy_auth_encrypted,
2388 proxy_auth_credential_ref,
2389 headless_auth,
2390 server,
2391 } = network;
2392 let ProviderRoutingConfigSection {
2393 provider,
2394 defaults,
2395 provider_instances,
2396 default_provider_instance,
2397 } = provider_routing;
2398 let ModelBehaviorConfigSection {
2399 keyword_masking,
2400 anthropic_model_mapping,
2401 gemini_model_mapping,
2402 patrol_model,
2403 hooks,
2404 lifecycle_hooks,
2405 } = model_behavior;
2406 let ToolingConfigSection { tools, skills, mcp } = tooling;
2407 let WorkspaceConfigSection {
2408 env_vars,
2409 default_work_area,
2410 worktrees,
2411 access_control,
2412 } = workspace;
2413 let ExecutionConfigSection {
2414 features,
2415 run_budget,
2416 stream_timeout,
2417 llm_retry,
2418 context_management,
2419 cluster_fabric,
2420 } = execution;
2421 let IntegrationConfigSection {
2422 notifications,
2423 connect,
2424 } = integrations;
2425 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2426
2427 Self {
2428 http_proxy,
2429 https_proxy,
2430 proxy_auth,
2431 proxy_auth_encrypted,
2432 proxy_auth_credential_ref,
2433 headless_auth,
2434 provider,
2435 defaults,
2436 provider_instances,
2437 default_provider_instance,
2438 server,
2439 keyword_masking,
2440 anthropic_model_mapping,
2441 gemini_model_mapping,
2442 patrol_model,
2443 hooks,
2444 lifecycle_hooks,
2445 tools,
2446 skills,
2447 env_vars,
2448 default_work_area,
2449 worktrees,
2450 access_control,
2451 features,
2452 run_budget,
2453 stream_timeout,
2454 llm_retry,
2455 context_management,
2456 cluster_fabric,
2457 mcp,
2458 notifications,
2459 connect,
2460 plugin_trust,
2461 extra,
2462 }
2463 }
2464}
2465
2466fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2473 let instance_native = values
2474 .default_provider_instance
2475 .as_ref()
2476 .is_some_and(|id| values.provider_instances.contains_key(id));
2477 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2478 if instance_native {
2479 if let Some(object) = value.as_object_mut() {
2480 object.remove("provider");
2481 object.remove("providers");
2482 }
2483 }
2484 Ok(value)
2485}
2486
2487define_counted_struct! {
2488 #[derive(Debug, Clone)]
2491 pub struct Config {
2492 values: ConfigValues,
2493 pub(crate) memory: crate::MemoryConfigModule,
2494 pub(crate) subagents: crate::SubagentsConfigModule,
2495 pub(crate) providers: crate::ProviderConfigsModule,
2496 recovery_status: Option<ConfigRecoveryStatus>,
2497 }
2498 count pub CONFIG_FIELD_COUNT;
2499}
2500
2501pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2503const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2504const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2505
2506impl std::ops::Deref for Config {
2507 type Target = ConfigValues;
2508
2509 fn deref(&self) -> &Self::Target {
2510 &self.values
2511 }
2512}
2513
2514impl std::ops::DerefMut for Config {
2515 fn deref_mut(&mut self) -> &mut Self::Target {
2516 &mut self.values
2517 }
2518}
2519
2520impl Serialize for Config {
2521 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2522 where
2523 S: serde::Serializer,
2524 {
2525 self.to_compatibility_value()
2526 .map_err(serde::ser::Error::custom)?
2527 .serialize(serializer)
2528 }
2529}
2530
2531impl<'de> Deserialize<'de> for Config {
2532 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2533 where
2534 D: serde::Deserializer<'de>,
2535 {
2536 use serde::de::Error;
2537
2538 let mut value = Value::deserialize(deserializer)?;
2539 let object = value
2540 .as_object_mut()
2541 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2542 let memory = object
2543 .remove("memory")
2544 .map(serde_json::from_value)
2545 .transpose()
2546 .map_err(D::Error::custom)?
2547 .unwrap_or_default();
2548 let subagents = object
2549 .remove("subagents")
2550 .map(serde_json::from_value)
2551 .transpose()
2552 .map_err(D::Error::custom)?
2553 .unwrap_or_default();
2554 let providers = object
2555 .remove("providers")
2556 .map(serde_json::from_value)
2557 .transpose()
2558 .map_err(D::Error::custom)?
2559 .unwrap_or_default();
2560 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2561 if let Some(model) = &root.model_behavior.patrol_model {
2562 model.validate().map_err(D::Error::custom)?;
2563 }
2564
2565 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2566 }
2567}
2568
2569#[derive(Debug, Clone, PartialEq, Serialize)]
2571#[serde(tag = "kind", rename_all = "snake_case")]
2572pub enum ConfigRecoverySource {
2573 Salvaged { fields: Vec<String> },
2578 Backup { generation: usize },
2581 Defaults,
2583}
2584
2585#[derive(Debug, Clone, PartialEq, Serialize)]
2597pub struct ConfigRecoveryStatus {
2598 pub source: ConfigRecoverySource,
2600 pub quarantine_path: Option<PathBuf>,
2606 pub confirmed: bool,
2609}
2610
2611#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2615pub struct ProviderConfigs {
2616 #[serde(skip_serializing_if = "Option::is_none")]
2618 pub openai: Option<OpenAIConfig>,
2619 #[serde(skip_serializing_if = "Option::is_none")]
2621 pub anthropic: Option<AnthropicConfig>,
2622 #[serde(skip_serializing_if = "Option::is_none")]
2624 pub gemini: Option<GeminiConfig>,
2625 #[serde(skip_serializing_if = "Option::is_none")]
2627 pub copilot: Option<CopilotConfig>,
2628 #[serde(skip_serializing_if = "Option::is_none")]
2630 pub bodhi: Option<BodhiConfig>,
2631
2632 #[serde(default, flatten)]
2634 pub extra: BTreeMap<String, Value>,
2635}
2636
2637impl ProviderConfigs {
2638 pub fn clear_legacy_builtin_aliases(&mut self) {
2641 self.openai = None;
2642 self.anthropic = None;
2643 self.gemini = None;
2644 self.copilot = None;
2645 self.bodhi = None;
2646 self.extra.remove("provider");
2647 }
2648}
2649
2650#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2652pub struct FeatureFlags {
2653 #[serde(default)]
2655 pub ticket_mutation: bool,
2656 #[serde(default)]
2658 pub ticket_dispatch: bool,
2659 #[serde(default)]
2661 pub provider_model_ref: bool,
2662 #[serde(default)]
2664 pub dynamic_model_routing: bool,
2665 #[serde(default)]
2667 pub final_evidence_check: bool,
2668}
2669
2670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2674pub struct DefaultsConfig {
2675 pub chat: bamboo_domain::ProviderModelRef,
2676 #[serde(default, skip_serializing_if = "Option::is_none")]
2677 pub fast: Option<bamboo_domain::ProviderModelRef>,
2678 #[serde(default, skip_serializing_if = "Option::is_none")]
2679 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2680 #[serde(default, skip_serializing_if = "Option::is_none")]
2681 pub vision: Option<bamboo_domain::ProviderModelRef>,
2682 #[serde(default, skip_serializing_if = "Option::is_none")]
2683 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2684 #[serde(default, skip_serializing_if = "Option::is_none")]
2687 pub planning: Option<bamboo_domain::ProviderModelRef>,
2688 #[serde(default, skip_serializing_if = "Option::is_none")]
2691 pub search: Option<bamboo_domain::ProviderModelRef>,
2692 #[serde(default, skip_serializing_if = "Option::is_none")]
2695 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2696 #[serde(
2699 default,
2700 skip_serializing_if = "Option::is_none",
2701 alias = "sub_session"
2702 )]
2703 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2704 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2708 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2709}
2710
2711#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2713pub struct HooksConfig {
2714 #[serde(default)]
2716 pub image_fallback: ImageFallbackHookConfig,
2717}
2718
2719pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2721pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2723pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2727
2728pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2730 "SessionStart",
2731 "UserPromptSubmit",
2732 "PreToolUse",
2733 "PostToolUse",
2734 "Stop",
2735 "SessionEnd",
2736 "PreCompact",
2737 "Notification",
2738 "BambooModelCallUsage",
2739];
2740
2741fn default_lifecycle_hook_timeout_ms() -> u64 {
2742 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2743}
2744
2745fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2746 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2747}
2748
2749fn lifecycle_hook_enabled_default() -> bool {
2750 true
2751}
2752
2753fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2754 *value
2755}
2756
2757#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2762pub struct LifecycleHooksConfig {
2763 #[serde(default)]
2764 pub enabled: bool,
2765 #[serde(
2766 default,
2767 rename = "SessionStart",
2768 skip_serializing_if = "Vec::is_empty"
2769 )]
2770 pub session_start: Vec<LifecycleHookGroup>,
2771 #[serde(
2772 default,
2773 rename = "UserPromptSubmit",
2774 skip_serializing_if = "Vec::is_empty"
2775 )]
2776 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2777 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2778 pub pre_tool_use: Vec<LifecycleHookGroup>,
2779 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2780 pub post_tool_use: Vec<LifecycleHookGroup>,
2781 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2782 pub stop: Vec<LifecycleHookGroup>,
2783 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2784 pub session_end: Vec<LifecycleHookGroup>,
2785 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2786 pub pre_compact: Vec<LifecycleHookGroup>,
2787 #[serde(
2788 default,
2789 rename = "Notification",
2790 skip_serializing_if = "Vec::is_empty"
2791 )]
2792 pub notification: Vec<LifecycleHookGroup>,
2793 #[serde(
2794 default,
2795 rename = "BambooModelCallUsage",
2796 skip_serializing_if = "Vec::is_empty"
2797 )]
2798 pub model_call_usage: Vec<LifecycleHookGroup>,
2799}
2800
2801impl LifecycleHooksConfig {
2802 pub fn is_empty(&self) -> bool {
2803 !self.enabled
2804 && self.session_start.is_empty()
2805 && self.user_prompt_submit.is_empty()
2806 && self.pre_tool_use.is_empty()
2807 && self.post_tool_use.is_empty()
2808 && self.stop.is_empty()
2809 && self.session_end.is_empty()
2810 && self.pre_compact.is_empty()
2811 && self.notification.is_empty()
2812 && self.model_call_usage.is_empty()
2813 }
2814}
2815
2816#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2818pub struct LifecycleHookGroup {
2819 #[serde(
2822 default = "lifecycle_hook_enabled_default",
2823 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2824 )]
2825 pub enabled: bool,
2826 #[serde(default, skip_serializing_if = "Option::is_none")]
2827 pub matcher: Option<String>,
2828 #[serde(default)]
2829 pub hooks: Vec<LifecycleHookHandler>,
2830}
2831
2832impl Default for LifecycleHookGroup {
2833 fn default() -> Self {
2834 Self {
2835 enabled: true,
2836 matcher: None,
2837 hooks: Vec::new(),
2838 }
2839 }
2840}
2841
2842#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2847#[serde(tag = "type", rename_all = "lowercase")]
2848pub enum LifecycleHookHandler {
2849 Command {
2850 command: String,
2851 #[serde(
2852 default = "default_lifecycle_hook_timeout_ms",
2853 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2854 )]
2855 timeout_ms: u64,
2856 },
2857 Script {
2858 path: String,
2859 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2860 runner: LifecycleScriptRunner,
2861 #[serde(
2862 default = "default_lifecycle_hook_timeout_ms",
2863 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2864 )]
2865 timeout_ms: u64,
2866 },
2867}
2868
2869#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2875#[serde(rename_all = "lowercase")]
2876pub enum LifecycleScriptRunner {
2877 #[default]
2878 Auto,
2879 Node,
2880 Bun,
2881 Python,
2882 Bash,
2883 PowerShell,
2884 Cmd,
2885}
2886
2887impl LifecycleScriptRunner {
2888 pub fn is_auto(&self) -> bool {
2889 matches!(self, Self::Auto)
2890 }
2891
2892 pub fn as_str(self) -> &'static str {
2893 match self {
2894 Self::Auto => "auto",
2895 Self::Node => "node",
2896 Self::Bun => "bun",
2897 Self::Python => "python",
2898 Self::Bash => "bash",
2899 Self::PowerShell => "powershell",
2900 Self::Cmd => "cmd",
2901 }
2902 }
2903
2904 pub fn supports_path(self, path: &str) -> bool {
2906 let extension = lifecycle_script_extension(path);
2907 match self {
2908 Self::Auto => extension.is_some(),
2909 Self::Node | Self::Bun => {
2910 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2911 }
2912 Self::Python => matches!(extension.as_deref(), Some("py")),
2913 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2914 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2915 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2916 }
2917 }
2918}
2919
2920pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2923 let extension = std::path::Path::new(path)
2924 .extension()?
2925 .to_str()?
2926 .to_ascii_lowercase();
2927 matches!(
2928 extension.as_str(),
2929 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2930 )
2931 .then_some(extension)
2932}
2933
2934impl LifecycleHookHandler {
2935 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2936 Self::Command {
2937 command: command.into(),
2938 timeout_ms,
2939 }
2940 }
2941
2942 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2943 Self::Script {
2944 path: path.into(),
2945 runner,
2946 timeout_ms,
2947 }
2948 }
2949
2950 pub fn timeout_ms(&self) -> u64 {
2951 match self {
2952 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2953 }
2954 }
2955}
2956
2957#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2964pub struct RequestOverridesConfig {
2965 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2967 pub common: RequestScopeOverride,
2968 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2970 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2971 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2973 pub rules: Vec<ModelRequestRule>,
2974}
2975
2976#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2978pub struct ModelRequestRule {
2979 pub model_pattern: String,
2981 #[serde(default, skip_serializing_if = "Option::is_none")]
2983 pub endpoint: Option<String>,
2984 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2986 pub scope: RequestScopeOverride,
2987}
2988
2989#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2991pub struct RequestScopeOverride {
2992 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2994 pub headers: BTreeMap<String, TemplateExpr>,
2995 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2997 pub body_patch: Vec<BodyPatch>,
2998}
2999
3000impl RequestScopeOverride {
3001 pub fn is_empty(&self) -> bool {
3002 self.headers.is_empty() && self.body_patch.is_empty()
3003 }
3004}
3005
3006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3008pub struct BodyPatch {
3009 pub path: String,
3011 #[serde(default)]
3013 pub op: BodyPatchOp,
3014 #[serde(default, skip_serializing_if = "Option::is_none")]
3016 pub value: Option<PatchValue>,
3017}
3018
3019#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3021#[serde(rename_all = "snake_case")]
3022pub enum BodyPatchOp {
3023 #[default]
3024 Set,
3025 Remove,
3026}
3027
3028#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3030#[serde(untagged)]
3031pub enum PatchValue {
3032 Template(TemplateExpr),
3033 Json(Value),
3034}
3035
3036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3038#[serde(untagged)]
3039pub enum TemplateExpr {
3040 Literal(String),
3042 Structured(TemplateExprSpec),
3044}
3045
3046#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3048#[serde(tag = "type", rename_all = "snake_case")]
3049pub enum TemplateExprSpec {
3050 Literal { value: String },
3052 EnvRef {
3054 name: String,
3055 #[serde(default, skip_serializing_if = "Option::is_none")]
3056 fallback: Option<String>,
3057 },
3058 Generated { generator: GeneratedValue },
3060 Format { template: String },
3062}
3063
3064#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3066#[serde(rename_all = "snake_case")]
3067pub enum GeneratedValue {
3068 Uuid,
3069 UnixMs,
3070}
3071
3072#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3074pub struct ToolsConfig {
3075 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3077 pub disabled: Vec<String>,
3078
3079 #[serde(default, flatten)]
3081 pub extra: BTreeMap<String, Value>,
3082}
3083
3084impl ToolsConfig {
3085 fn is_empty(&self) -> bool {
3086 self.disabled.is_empty() && self.extra.is_empty()
3087 }
3088}
3089
3090#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3092pub struct SkillsConfig {
3093 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3095 pub disabled: Vec<String>,
3096
3097 #[serde(default, flatten)]
3099 pub extra: BTreeMap<String, Value>,
3100}
3101
3102impl SkillsConfig {
3103 fn is_empty(&self) -> bool {
3104 self.disabled.is_empty() && self.extra.is_empty()
3105 }
3106}
3107
3108#[derive(Debug, Clone, Serialize, Deserialize)]
3113pub struct ImageFallbackHookConfig {
3114 #[serde(default = "default_true_hooks")]
3115 pub enabled: bool,
3116
3117 #[serde(default = "default_image_fallback_mode")]
3119 pub mode: String,
3120}
3121
3122impl Default for ImageFallbackHookConfig {
3123 fn default() -> Self {
3124 Self {
3125 enabled: default_true_hooks(),
3126 mode: default_image_fallback_mode(),
3127 }
3128 }
3129}
3130
3131fn default_image_fallback_mode() -> String {
3132 "placeholder".to_string()
3133}
3134
3135fn default_true_hooks() -> bool {
3136 false
3139}
3140
3141pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3153
3154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3155pub struct OpenAIConfig {
3156 #[serde(default, skip_serializing)]
3160 pub api_key: String,
3161 #[serde(default, skip_serializing_if = "Option::is_none")]
3163 pub api_key_encrypted: Option<String>,
3164 #[serde(default, skip_serializing_if = "Option::is_none")]
3166 pub credential_ref: Option<crate::CredentialRef>,
3167 #[serde(skip)]
3172 pub api_key_from_env: bool,
3173 #[serde(skip_serializing_if = "Option::is_none")]
3175 pub base_url: Option<String>,
3176 #[serde(skip_serializing_if = "Option::is_none")]
3178 pub model: Option<String>,
3179 #[serde(default, skip_serializing_if = "Option::is_none")]
3182 pub fast_model: Option<String>,
3183 #[serde(default, skip_serializing_if = "Option::is_none")]
3186 pub vision_model: Option<String>,
3187 #[serde(skip_serializing_if = "Option::is_none")]
3189 pub reasoning_effort: Option<ReasoningEffort>,
3190
3191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3198 pub responses_only_models: Vec<String>,
3199 #[serde(default, skip_serializing_if = "Option::is_none")]
3201 pub request_overrides: Option<RequestOverridesConfig>,
3202
3203 #[serde(default, flatten)]
3205 pub extra: BTreeMap<String, Value>,
3206}
3207
3208impl OpenAIConfig {
3209 pub fn explicit_prompt_cache_enabled(&self) -> bool {
3216 self.extra
3217 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3218 .and_then(Value::as_bool)
3219 .unwrap_or(true)
3220 }
3221}
3222
3223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3235pub struct AnthropicConfig {
3236 #[serde(default, skip_serializing)]
3240 pub api_key: String,
3241 #[serde(default, skip_serializing_if = "Option::is_none")]
3243 pub api_key_encrypted: Option<String>,
3244 #[serde(default, skip_serializing_if = "Option::is_none")]
3246 pub credential_ref: Option<crate::CredentialRef>,
3247 #[serde(skip)]
3252 pub api_key_from_env: bool,
3253 #[serde(skip_serializing_if = "Option::is_none")]
3255 pub base_url: Option<String>,
3256 #[serde(skip_serializing_if = "Option::is_none")]
3258 pub model: Option<String>,
3259 #[serde(default, skip_serializing_if = "Option::is_none")]
3262 pub fast_model: Option<String>,
3263 #[serde(default, skip_serializing_if = "Option::is_none")]
3266 pub vision_model: Option<String>,
3267 #[serde(skip_serializing_if = "Option::is_none")]
3269 pub max_tokens: Option<u32>,
3270 #[serde(skip_serializing_if = "Option::is_none")]
3272 pub reasoning_effort: Option<ReasoningEffort>,
3273 #[serde(default, skip_serializing_if = "Option::is_none")]
3275 pub request_overrides: Option<RequestOverridesConfig>,
3276
3277 #[serde(default, skip_serializing_if = "Option::is_none")]
3294 pub thinking_replay_always: Option<bool>,
3295
3296 #[serde(default, flatten)]
3298 pub extra: BTreeMap<String, Value>,
3299}
3300
3301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3312pub struct GeminiConfig {
3313 #[serde(default, skip_serializing)]
3317 pub api_key: String,
3318 #[serde(default, skip_serializing_if = "Option::is_none")]
3320 pub api_key_encrypted: Option<String>,
3321 #[serde(default, skip_serializing_if = "Option::is_none")]
3323 pub credential_ref: Option<crate::CredentialRef>,
3324 #[serde(skip)]
3329 pub api_key_from_env: bool,
3330 #[serde(skip_serializing_if = "Option::is_none")]
3332 pub base_url: Option<String>,
3333 #[serde(skip_serializing_if = "Option::is_none")]
3335 pub model: Option<String>,
3336 #[serde(default, skip_serializing_if = "Option::is_none")]
3339 pub fast_model: Option<String>,
3340 #[serde(default, skip_serializing_if = "Option::is_none")]
3343 pub vision_model: Option<String>,
3344 #[serde(skip_serializing_if = "Option::is_none")]
3346 pub reasoning_effort: Option<ReasoningEffort>,
3347 #[serde(default, skip_serializing_if = "Option::is_none")]
3349 pub request_overrides: Option<RequestOverridesConfig>,
3350
3351 #[serde(default, flatten)]
3353 pub extra: BTreeMap<String, Value>,
3354}
3355
3356#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3368pub struct CopilotConfig {
3369 #[serde(default)]
3371 pub enabled: bool,
3372 #[serde(default)]
3374 pub headless_auth: bool,
3375 #[serde(skip_serializing_if = "Option::is_none")]
3377 pub model: Option<String>,
3378 #[serde(default, skip_serializing_if = "Option::is_none")]
3381 pub fast_model: Option<String>,
3382 #[serde(default, skip_serializing_if = "Option::is_none")]
3385 pub vision_model: Option<String>,
3386 #[serde(skip_serializing_if = "Option::is_none")]
3388 pub reasoning_effort: Option<ReasoningEffort>,
3389
3390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3399 pub responses_only_models: Vec<String>,
3400 #[serde(default, skip_serializing_if = "Option::is_none")]
3402 pub request_overrides: Option<RequestOverridesConfig>,
3403
3404 #[serde(default, flatten)]
3406 pub extra: BTreeMap<String, Value>,
3407}
3408
3409#[derive(Debug, Clone, Serialize, Deserialize)]
3414pub struct BodhiConfig {
3415 #[serde(default, skip_serializing)]
3417 pub api_key: String,
3418 #[serde(default, skip_serializing_if = "Option::is_none")]
3420 pub api_key_encrypted: Option<String>,
3421 #[serde(default, skip_serializing_if = "Option::is_none")]
3423 pub credential_ref: Option<crate::CredentialRef>,
3424 #[serde(skip_serializing_if = "Option::is_none")]
3426 pub base_url: Option<String>,
3427 #[serde(skip_serializing_if = "Option::is_none")]
3429 pub target_provider: Option<String>,
3430 #[serde(skip_serializing_if = "Option::is_none")]
3432 pub reasoning_effort: Option<ReasoningEffort>,
3433
3434 #[serde(default, flatten)]
3436 pub extra: BTreeMap<String, Value>,
3437}
3438
3439fn default_provider() -> String {
3441 "anthropic".to_string()
3442}
3443
3444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3476pub struct ProviderInstanceConfig {
3477 pub provider_type: String,
3482
3483 #[serde(default, skip_serializing_if = "Option::is_none")]
3485 pub label: Option<String>,
3486
3487 #[serde(default, skip_serializing)]
3489 pub api_key: String,
3490
3491 #[serde(default, skip_serializing_if = "Option::is_none")]
3494 pub api_key_encrypted: Option<String>,
3495
3496 #[serde(default, skip_serializing_if = "Option::is_none")]
3498 pub credential_ref: Option<crate::CredentialRef>,
3499
3500 #[serde(default, skip_serializing_if = "Option::is_none")]
3502 pub base_url: Option<String>,
3503
3504 #[serde(default, skip_serializing_if = "Option::is_none")]
3506 pub model: Option<String>,
3507
3508 #[serde(default, skip_serializing_if = "Option::is_none")]
3510 pub fast_model: Option<String>,
3511
3512 #[serde(default, skip_serializing_if = "Option::is_none")]
3514 pub vision_model: Option<String>,
3515
3516 #[serde(default, skip_serializing_if = "Option::is_none")]
3518 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3519
3520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3522 pub responses_only_models: Vec<String>,
3523
3524 #[serde(default, skip_serializing_if = "Option::is_none")]
3526 pub request_overrides: Option<RequestOverridesConfig>,
3527
3528 #[serde(default = "default_true")]
3531 pub enabled: bool,
3532
3533 #[serde(default, flatten)]
3535 pub extra: BTreeMap<String, Value>,
3536}
3537
3538fn default_true() -> bool {
3539 true
3540}
3541
3542fn default_port() -> u16 {
3544 9562
3545}
3546
3547fn default_bind() -> String {
3549 "127.0.0.1".to_string()
3550}
3551
3552fn default_workers() -> usize {
3554 10
3555}
3556
3557fn default_data_dir() -> PathBuf {
3559 super::paths::bamboo_dir()
3560}
3561
3562#[derive(Debug, Clone, Serialize, Deserialize)]
3564pub struct ServerConfig {
3565 #[serde(default = "default_port")]
3567 pub port: u16,
3568
3569 #[serde(default = "default_bind")]
3571 pub bind: String,
3572
3573 pub static_dir: Option<PathBuf>,
3575
3576 #[serde(default = "default_workers")]
3578 pub workers: usize,
3579
3580 #[serde(default, skip_serializing_if = "Option::is_none")]
3588 pub tls: Option<TlsConfig>,
3589
3590 #[serde(default, flatten)]
3592 pub extra: BTreeMap<String, Value>,
3593}
3594
3595impl Default for ServerConfig {
3596 fn default() -> Self {
3597 Self {
3598 port: default_port(),
3599 bind: default_bind(),
3600 static_dir: None,
3601 workers: default_workers(),
3602 tls: None,
3603 extra: BTreeMap::new(),
3604 }
3605 }
3606}
3607
3608#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3614pub struct TlsConfig {
3615 pub cert_file: PathBuf,
3617 pub key_file: PathBuf,
3619}
3620
3621#[derive(Debug, Clone, Serialize, Deserialize)]
3623pub struct ProxyAuth {
3624 pub username: String,
3626 pub password: String,
3628}
3629
3630fn parse_bool_env(value: &str) -> bool {
3634 matches!(
3635 value.trim().to_ascii_lowercase().as_str(),
3636 "1" | "true" | "yes" | "y" | "on"
3637 )
3638}
3639
3640fn expand_user_path(value: &str) -> PathBuf {
3641 let trimmed = value.trim();
3642 if let Some(rest) = trimmed.strip_prefix("~/") {
3643 if let Some(home) = dirs::home_dir() {
3644 return home.join(rest);
3645 }
3646 }
3647 PathBuf::from(trimmed)
3648}
3649
3650impl Default for Config {
3651 fn default() -> Self {
3652 Self::create_default()
3661 }
3662}
3663
3664#[derive(Debug, Clone, PartialEq, Eq)]
3666pub struct PromptSafeEnvVarEntry {
3667 pub name: String,
3668 pub secret: bool,
3669 pub description: Option<String>,
3670}
3671
3672static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3676
3677static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3678
3679fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3680 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3681}
3682
3683fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3684 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3685}
3686
3687impl Config {
3688 fn from_parts(
3689 values: ConfigValues,
3690 memory: Option<MemoryConfig>,
3691 subagents: SubagentsConfig,
3692 providers: ProviderConfigs,
3693 ) -> Self {
3694 Self {
3695 values,
3696 memory: crate::MemoryConfigModule(memory),
3697 subagents: crate::SubagentsConfigModule(subagents),
3698 providers: crate::ProviderConfigsModule(providers),
3699 recovery_status: None,
3700 }
3701 }
3702
3703 pub(crate) fn section_values(&self) -> ConfigValues {
3709 self.values.clone()
3710 }
3711
3712 pub(crate) fn from_section_parts(
3713 values: ConfigValues,
3714 memory: Option<MemoryConfig>,
3715 subagents: SubagentsConfig,
3716 providers: ProviderConfigs,
3717 ) -> Self {
3718 Self::from_parts(values, memory, subagents, providers)
3719 }
3720
3721 pub fn memory(&self) -> &Option<MemoryConfig> {
3723 &self.memory.0
3724 }
3725
3726 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3727 &mut self.memory.0
3728 }
3729
3730 pub fn subagents(&self) -> &SubagentsConfig {
3732 &self.subagents.0
3733 }
3734
3735 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3736 &mut self.subagents.0
3737 }
3738
3739 pub fn providers(&self) -> &ProviderConfigs {
3741 &self.providers.0
3742 }
3743
3744 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3745 &mut self.providers.0
3746 }
3747
3748 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3752 if self
3753 .default_provider_instance
3754 .as_ref()
3755 .is_some_and(|id| self.provider_instances.contains_key(id))
3756 {
3757 self.providers.0.clear_legacy_builtin_aliases();
3758 }
3759 }
3760
3761 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3767 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3768 let object = value
3769 .as_object_mut()
3770 .expect("ConfigRoot always serializes as a JSON object");
3771 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3772 object.insert(
3773 "subagents".to_string(),
3774 serde_json::to_value(self.subagents())?,
3775 );
3776 object.insert(
3777 "providers".to_string(),
3778 serde_json::to_value(self.providers())?,
3779 );
3780 Ok(value)
3781 }
3782
3783 pub fn new() -> Self {
3802 Self::from_data_dir(None)
3803 }
3804
3805 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3806 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3807 Ok(facade) => facade.effective_config(),
3808 Err(error) => {
3809 tracing::warn!(
3810 error = %error,
3811 "completed modular configuration is unavailable; refusing legacy-root fallback"
3812 );
3813 Self::create_default()
3814 }
3815 };
3816
3817 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3818 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3819 config.proxy_auth = None;
3820 }
3821 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3822 tracing::warn!(error = %error, "provider credential hydration unavailable");
3823 }
3824 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3825 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3826 }
3827 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3828 tracing::warn!(error = %error, "env credential hydration unavailable");
3829 for entry in &mut config.env_vars {
3830 if entry.secret {
3831 entry.value.clear();
3832 }
3833 }
3834 }
3835 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3836 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3837 config.clear_cluster_runtime_credentials();
3838 }
3839 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3840 tracing::warn!(error = %error, "notification credential hydration unavailable");
3841 config.notifications.ntfy.token = None;
3842 config.notifications.bark.device_key = None;
3843 }
3844 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3845 tracing::warn!(error = %error, "connect credential hydration unavailable");
3846 for platform in &mut config.connect.platforms {
3847 platform.token = None;
3848 platform.app_secret = None;
3849 }
3850 }
3851 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3852 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3853 config.clear_access_control_runtime_verifiers();
3854 }
3855 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3856 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3857 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3858 broker.token.clear();
3859 }
3860 }
3861 config.normalize_tool_settings();
3862 config.normalize_skill_settings();
3863 config.normalize_plugin_trust_settings();
3864 config.extra.remove("data_dir");
3865 if apply_env {
3866 config.apply_env_overrides();
3867 }
3868 if publish {
3869 config.publish_env_vars();
3870 }
3871 config
3872 }
3873
3874 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3883 let data_dir = data_dir
3885 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3886 .unwrap_or_else(default_data_dir);
3887
3888 match crate::modular_authority_boundary_present(&data_dir) {
3889 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3890 Ok(false) => {}
3891 Err(error) => {
3892 tracing::warn!(
3893 error = %error,
3894 "modular configuration marker is unavailable; refusing legacy-root fallback"
3895 );
3896 let mut config = Self::create_default();
3897 if apply_env {
3898 config.apply_env_overrides();
3899 }
3900 if publish {
3901 config.publish_env_vars();
3902 }
3903 return config;
3904 }
3905 }
3906
3907 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3912 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3913 .map_err(|error| {
3914 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3915 error
3916 })
3917 .is_ok();
3918 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3919 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3920 .map_err(|error| {
3921 tracing::warn!(error = %error, "cluster credential migration unavailable");
3922 error
3923 })
3924 .is_ok();
3925
3926 match crate::modular_authority_boundary_present(&data_dir) {
3931 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3932 Ok(false) => {}
3933 Err(error) => {
3934 tracing::warn!(
3935 error = %error,
3936 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3937 );
3938 let mut config = Self::create_default();
3939 if apply_env {
3940 config.apply_env_overrides();
3941 }
3942 if publish {
3943 config.publish_env_vars();
3944 }
3945 return config;
3946 }
3947 }
3948
3949 let config_path = data_dir.join("config.json");
3950
3951 let mut config = if config_path.exists() {
3952 if let Ok(content) = std::fs::read_to_string(&config_path) {
3953 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3954 tracing::warn!(
3964 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3965 e
3966 );
3967 let quarantine_path = quarantine_corrupt_config(&config_path);
3968 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3969 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3970 .or_else(|| {
3971 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3972 (cfg, ConfigRecoverySource::Backup { generation })
3973 })
3974 })
3975 .unwrap_or_else(|| {
3976 tracing::warn!(
3977 "Could not salvage and no usable config.json.bak; using defaults"
3978 );
3979 (Self::create_default(), ConfigRecoverySource::Defaults)
3980 });
3981 recovered.recovery_status = Some(ConfigRecoveryStatus {
3982 source,
3983 quarantine_path,
3984 confirmed: false,
3985 });
3986 recovered
3987 })
3988 } else {
3989 Self::create_default()
3990 }
3991 } else {
3992 Self::create_default()
3993 };
3994
3995 let mut memory_module = config.memory.clone();
4001 match memory_module.load_sync(&data_dir) {
4002 Ok(true) => config.memory = memory_module,
4003 Ok(false) => {}
4004 Err(error) => tracing::warn!(
4005 "Failed to load memory.json; using legacy config.json memory: {error}"
4006 ),
4007 }
4008 let mut subagents_module = config.subagents.clone();
4009 match subagents_module.load_sync(&data_dir) {
4010 Ok(true) => config.subagents = subagents_module,
4011 Ok(false) => {}
4012 Err(error) => tracing::warn!(
4013 "Failed to load subagents.json; using legacy config.json subagents: {error}"
4014 ),
4015 }
4016 if provider_mcp_ready {
4017 let mut providers_module = config.providers.clone();
4018 match providers_module.load_sync(&data_dir) {
4019 Ok(true) => config.providers = providers_module,
4020 Ok(false) => {}
4021 Err(error) => tracing::warn!(
4022 "Failed to load providers.json; using legacy config.json providers: {error}"
4023 ),
4024 }
4025 }
4026
4027 config.hydrate_proxy_auth_from_encrypted();
4029 if provider_mcp_ready {
4030 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4031 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4032 config.proxy_auth = None;
4033 }
4034 }
4035 config.hydrate_provider_api_keys_from_encrypted();
4037 config.hydrate_provider_instance_api_keys_from_encrypted();
4039 config.hydrate_mcp_secrets_from_encrypted();
4041 if provider_mcp_ready {
4042 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4043 tracing::warn!(error = %error, "provider credential hydration unavailable");
4044 }
4045 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4046 tracing::warn!(error = %error, "MCP credential hydration unavailable");
4047 }
4048 }
4049 config.hydrate_env_vars_from_encrypted();
4051 if provider_mcp_ready {
4052 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4053 tracing::warn!(error = %error, "env credential hydration unavailable");
4054 for entry in &mut config.env_vars {
4055 if entry.secret {
4056 entry.value.clear();
4057 }
4058 }
4059 }
4060 }
4061 if cluster_ready {
4066 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4067 tracing::warn!(error = %error, "cluster credential hydration unavailable");
4068 config.clear_cluster_runtime_credentials();
4069 }
4070 } else {
4071 config.clear_cluster_runtime_credentials();
4072 }
4073 config.hydrate_broker_token_from_encrypted();
4075 config.hydrate_notifications_from_encrypted();
4077 if provider_mcp_ready {
4078 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4079 tracing::warn!(error = %error, "notification credential hydration unavailable");
4080 config.notifications.ntfy.token = None;
4081 config.notifications.bark.device_key = None;
4082 }
4083 } else {
4084 config.notifications.ntfy.token = None;
4089 config.notifications.bark.device_key = None;
4090 }
4091 config.merge_connect_config(&data_dir);
4096 scrub_legacy_connect_from_config_backups(&data_dir);
4106 config.hydrate_connect_platform_tokens_from_encrypted();
4108 if provider_mcp_ready {
4109 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4110 tracing::warn!(error = %error, "connect credential hydration unavailable");
4111 for platform in &mut config.connect.platforms {
4112 platform.token = None;
4113 platform.app_secret = None;
4114 }
4115 }
4116 }
4117 if provider_mcp_ready {
4118 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4119 tracing::warn!(error = %error, "access-control credential hydration unavailable");
4120 config.clear_access_control_runtime_verifiers();
4121 }
4122 } else {
4123 config.clear_access_control_runtime_verifiers();
4124 }
4125 config.normalize_tool_settings();
4126 config.normalize_skill_settings();
4127 config.normalize_plugin_trust_settings();
4128
4129 config.extra.remove("data_dir");
4132
4133 if apply_env {
4137 config.apply_env_overrides();
4138 }
4139
4140 if publish {
4144 config.publish_env_vars();
4145 }
4146
4147 config
4148 }
4149
4150 fn apply_env_overrides(&mut self) {
4154 if let Ok(port) = std::env::var("BAMBOO_PORT") {
4155 if let Ok(port) = port.parse() {
4156 self.server.port = port;
4157 }
4158 }
4159
4160 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4161 self.server.bind = bind;
4162 }
4163
4164 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4169 let provider = provider.trim().to_string();
4170 if !provider.is_empty() {
4171 self.provider = provider.clone();
4172 if !self.provider_instances.is_empty() {
4173 let selected = self
4174 .provider_instances
4175 .contains_key(&provider)
4176 .then_some(provider.clone())
4177 .or_else(|| {
4178 let mut matching = self
4179 .provider_instances
4180 .iter()
4181 .filter(|(_, instance)| {
4182 instance.enabled && instance.provider_type == provider
4183 })
4184 .map(|(id, _)| id.clone())
4185 .collect::<Vec<_>>();
4186 matching.sort();
4187 matching.into_iter().next()
4188 });
4189 self.default_provider_instance = selected;
4190 }
4191 }
4192 }
4193
4194 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4195 self.headless_auth = parse_bool_env(&headless);
4196 }
4197
4198 if let Ok(project_prompt_injection) =
4199 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4200 {
4201 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4202 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4203 }
4204
4205 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4206 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4207 memory.relevant_recall = parse_bool_env(&relevant_recall);
4208 }
4209
4210 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4211 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4212 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4213 }
4214
4215 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4216 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4217 memory.project_first_dream = parse_bool_env(&project_first_dream);
4218 }
4219
4220 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4227 let key = key.trim();
4228 if !key.is_empty() {
4229 if self.provider_instances.is_empty() {
4230 let openai = self
4231 .providers
4232 .openai
4233 .get_or_insert_with(OpenAIConfig::default);
4234 openai.api_key = key.to_string();
4235 openai.api_key_from_env = true;
4236 } else {
4237 self.apply_provider_instance_env_key("openai", key);
4238 }
4239 }
4240 }
4241 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4242 let key = key.trim();
4243 if !key.is_empty() {
4244 if self.provider_instances.is_empty() {
4245 let anthropic = self
4246 .providers
4247 .anthropic
4248 .get_or_insert_with(AnthropicConfig::default);
4249 anthropic.api_key = key.to_string();
4250 anthropic.api_key_from_env = true;
4251 } else {
4252 self.apply_provider_instance_env_key("anthropic", key);
4253 }
4254 }
4255 }
4256 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4257 let key = key.trim();
4258 if !key.is_empty() {
4259 if self.provider_instances.is_empty() {
4260 let gemini = self
4261 .providers
4262 .gemini
4263 .get_or_insert_with(GeminiConfig::default);
4264 gemini.api_key = key.to_string();
4265 gemini.api_key_from_env = true;
4266 } else {
4267 self.apply_provider_instance_env_key("gemini", key);
4268 }
4269 }
4270 }
4271 }
4272
4273 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4274 for instance in self.provider_instances.values_mut().filter(|instance| {
4275 instance.provider_type == provider_type
4276 && crate::provider_instance_api_key_from_env(instance)
4277 }) {
4278 instance.api_key = key.to_string();
4279 instance.api_key_encrypted = None;
4280 }
4281 }
4282
4283 pub fn apply_runtime_env_overrides(&mut self) {
4288 self.apply_env_overrides();
4289 }
4290
4291 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4297 Self::from_data_dir_impl(data_dir, true, true)
4298 }
4299
4300 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4304 Self::from_data_dir_impl(data_dir, false, true)
4305 }
4306
4307 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4314 Self::from_data_dir_impl(data_dir, false, false)
4315 }
4316
4317 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4345 let connect_path = data_dir.join("connect.json");
4346 match std::fs::read_to_string(&connect_path) {
4347 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4348 Ok(connect) => {
4349 let legacy_key_present = !connect_config_is_empty(&self.connect);
4350 if legacy_key_present {
4351 tracing::warn!(
4352 "config.json still has a legacy `connect` key alongside \
4353 connect.json; connect.json takes precedence — dropping the \
4354 stale key from config.json now"
4355 );
4356 }
4357 self.connect = connect;
4358 if legacy_key_present {
4359 strip_legacy_connect_key_from_config_json(data_dir);
4360 }
4361 }
4362 Err(e) => {
4363 tracing::error!(
4364 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4365 connect config instead of falling back to a legacy config.json copy",
4366 connect_path,
4367 e
4368 );
4369 quarantine_corrupt_connect(&connect_path);
4370 self.connect = ConnectConfig::default();
4371 }
4372 },
4373 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4374 if !connect_config_is_empty(&self.connect) {
4375 tracing::info!(
4376 "Migrating legacy `connect` config from config.json to a \
4377 standalone connect.json"
4378 );
4379 strip_legacy_connect_key_from_config_json(data_dir);
4383 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4384 tracing::error!("Failed to write connect.json during migration: {}", e);
4385 }
4386 }
4387 }
4388 Err(e) => {
4389 tracing::error!(
4390 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4391 connect_path,
4392 e
4393 );
4394 self.connect = ConnectConfig::default();
4395 }
4396 }
4397 }
4398
4399 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4402 serde_json::from_str::<Config>(content).map(|mut config| {
4403 config.hydrate_proxy_auth_from_encrypted();
4404 config.hydrate_provider_api_keys_from_encrypted();
4405 config.hydrate_provider_instance_api_keys_from_encrypted();
4406 config.hydrate_mcp_secrets_from_encrypted();
4407 config.hydrate_env_vars_from_encrypted();
4408 config.hydrate_cluster_fabric_from_encrypted();
4409 config.hydrate_broker_token_from_encrypted();
4410 config.hydrate_notifications_from_encrypted();
4411 config.hydrate_connect_platform_tokens_from_encrypted();
4412 config.normalize_tool_settings();
4413 config.normalize_skill_settings();
4414 config
4415 })
4416 }
4417
4418 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4424 let config_path = data_dir.join("config.json");
4425 for gen in 0..BAK_GENERATIONS {
4426 let backup = backup_path_for(&config_path, gen);
4427 let Ok(content) = std::fs::read_to_string(&backup) else {
4428 continue;
4429 };
4430 match Self::parse_and_hydrate(&content) {
4431 Ok(config) => {
4432 tracing::info!("Recovered configuration from {:?}", backup);
4433 return Some((config, gen));
4434 }
4435 Err(e) => {
4436 tracing::warn!(
4437 "Backup {:?} is unparseable ({}); trying an older generation",
4438 backup,
4439 e
4440 );
4441 }
4442 }
4443 }
4444 None
4445 }
4446
4447 const SALVAGE_MAX_KEYS: usize = 512;
4453
4454 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4481 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4484 let corrupt_obj = corrupt.as_object()?;
4485 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4486 tracing::warn!(
4487 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4488 corrupt_obj.len(),
4489 Self::SALVAGE_MAX_KEYS
4490 );
4491 return None;
4492 }
4493
4494 let mut base = Self::load_backup(data_dir)
4498 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4499 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4500 let base_obj = base.as_object_mut()?;
4501
4502 let mut salvaged: Vec<String> = Vec::new();
4503 for (key, value) in corrupt_obj {
4504 let previous = base_obj.insert(key.clone(), value.clone());
4505 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4509 salvaged.push(key.clone());
4510 } else {
4511 match previous {
4512 Some(prev) => {
4513 base_obj.insert(key.clone(), prev);
4514 }
4515 None => {
4516 base_obj.remove(key);
4517 }
4518 }
4519 }
4520 }
4521
4522 tracing::warn!(
4523 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4524 last-known-good/default value",
4525 salvaged.len(),
4526 salvaged.join(", ")
4527 );
4528
4529 let rebuilt = serde_json::to_string(&base).ok()?;
4533 Self::parse_and_hydrate(&rebuilt)
4534 .ok()
4535 .map(|config| (config, salvaged))
4536 }
4537
4538 pub fn get_model(&self) -> Option<String> {
4546 if self.features.provider_model_ref {
4547 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4548 return Some(model_ref.model.clone());
4549 }
4550 }
4551 let provider = self.effective_default_provider();
4552 if let Some(instance) = self.provider_instances.get(provider) {
4553 return instance
4554 .model
4555 .clone()
4556 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4557 }
4558 match provider {
4559 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4560 "anthropic" => self
4561 .providers
4562 .anthropic
4563 .as_ref()
4564 .and_then(|c| c.model.clone()),
4565 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4566 "copilot" => Some(
4567 self.providers
4568 .copilot
4569 .as_ref()
4570 .and_then(|c| c.model.clone())
4571 .unwrap_or_else(|| "gpt-4o".to_string()),
4572 ),
4573 _ => None,
4574 }
4575 }
4576
4577 pub fn get_fast_model(&self) -> Option<String> {
4585 if self.features.provider_model_ref {
4586 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4587 return Some(model_ref.model.clone());
4588 }
4589 }
4590 let provider = self.effective_default_provider();
4591 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4592 instance.fast_model.clone()
4593 } else {
4594 match provider {
4595 "openai" => self
4596 .providers
4597 .openai
4598 .as_ref()
4599 .and_then(|c| c.fast_model.clone()),
4600 "anthropic" => self
4601 .providers
4602 .anthropic
4603 .as_ref()
4604 .and_then(|c| c.fast_model.clone()),
4605 "gemini" => self
4606 .providers
4607 .gemini
4608 .as_ref()
4609 .and_then(|c| c.fast_model.clone()),
4610 "copilot" => self
4611 .providers
4612 .copilot
4613 .as_ref()
4614 .and_then(|c| c.fast_model.clone()),
4615 _ => None,
4616 }
4617 };
4618 fast.or_else(|| self.get_model())
4619 }
4620
4621 pub fn get_task_summary_model(&self) -> Option<String> {
4629 if self.features.provider_model_ref {
4630 if let Some(model_ref) = self
4631 .defaults
4632 .as_ref()
4633 .and_then(|d| d.task_summary.as_ref())
4634 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4635 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4636 {
4637 return Some(model_ref.model.clone());
4638 }
4639 }
4640
4641 self.get_fast_model()
4642 }
4643
4644 pub fn get_memory_background_model(&self) -> Option<String> {
4652 if self.features.provider_model_ref {
4653 if let Some(model_ref) = self
4654 .defaults
4655 .as_ref()
4656 .and_then(|d| d.memory_background.as_ref())
4657 {
4658 return Some(model_ref.model.clone());
4659 }
4660 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4661 return Some(model_ref.model.clone());
4662 }
4663 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4664 return Some(model_ref.model.clone());
4665 }
4666 }
4667 let configured = self
4668 .memory
4669 .as_ref()
4670 .and_then(|memory| memory.background_model.as_ref())
4671 .map(|value| value.trim())
4672 .filter(|value| !value.is_empty())
4673 .map(ToString::to_string);
4674 configured.or_else(|| self.get_fast_model())
4675 }
4676
4677 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4685 let raw = self
4686 .default_work_area
4687 .as_ref()
4688 .and_then(|config| config.path.as_ref())
4689 .map(|value| value.trim())
4690 .filter(|value| !value.is_empty())?;
4691
4692 let candidate = expand_user_path(raw);
4693 if candidate.is_absolute() {
4694 let canonical = std::fs::canonicalize(&candidate).ok();
4695 return canonical
4696 .as_ref()
4697 .filter(|path| path.is_dir())
4698 .map(|_| candidate.clone())
4699 .or_else(|| candidate.is_dir().then_some(candidate));
4700 }
4701
4702 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4703 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4704 canonical
4705 .as_ref()
4706 .filter(|path| path.is_dir())
4707 .map(|_| from_bamboo_dir.clone())
4708 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4709 .or_else(|| candidate.is_dir().then_some(candidate))
4710 }
4711
4712 pub fn get_vision_model(&self) -> Option<String> {
4718 if self.features.provider_model_ref {
4719 if let Some(model_ref) = self
4720 .defaults
4721 .as_ref()
4722 .and_then(|defaults| defaults.vision.as_ref())
4723 .or_else(|| {
4724 self.defaults
4725 .as_ref()
4726 .and_then(|defaults| defaults.fast.as_ref())
4727 })
4728 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4729 {
4730 return Some(model_ref.model.clone());
4731 }
4732 }
4733 let provider = self.effective_default_provider();
4734 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4735 instance.vision_model.clone()
4736 } else {
4737 match provider {
4738 "openai" => self
4739 .providers
4740 .openai
4741 .as_ref()
4742 .and_then(|c| c.vision_model.clone()),
4743 "anthropic" => self
4744 .providers
4745 .anthropic
4746 .as_ref()
4747 .and_then(|c| c.vision_model.clone()),
4748 "gemini" => self
4749 .providers
4750 .gemini
4751 .as_ref()
4752 .and_then(|c| c.vision_model.clone()),
4753 "copilot" => self
4754 .providers
4755 .copilot
4756 .as_ref()
4757 .and_then(|c| c.vision_model.clone()),
4758 _ => None,
4759 }
4760 };
4761 vision.or_else(|| self.get_fast_model())
4762 }
4763
4764 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4768 if self.features.provider_model_ref {
4769 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4770 return chat
4771 .reasoning_effort
4772 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4773 }
4774 }
4775 self.reasoning_effort_for_key(self.effective_default_provider())
4776 }
4777
4778 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4788 let trimmed = key.trim();
4789 if trimmed.is_empty() {
4790 return None;
4791 }
4792
4793 if let Some(instance) = self.provider_instances.get(trimmed) {
4795 return instance.reasoning_effort;
4796 }
4797
4798 match trimmed {
4800 "openai" => self
4801 .providers
4802 .openai
4803 .as_ref()
4804 .and_then(|c| c.reasoning_effort),
4805 "anthropic" => self
4806 .providers
4807 .anthropic
4808 .as_ref()
4809 .and_then(|c| c.reasoning_effort),
4810 "gemini" => self
4811 .providers
4812 .gemini
4813 .as_ref()
4814 .and_then(|c| c.reasoning_effort),
4815 "copilot" => self
4816 .providers
4817 .copilot
4818 .as_ref()
4819 .and_then(|c| c.reasoning_effort),
4820 "bodhi" => self
4821 .providers
4822 .bodhi
4823 .as_ref()
4824 .and_then(|c| c.reasoning_effort),
4825 _ => None,
4826 }
4827 }
4828
4829 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4836 self.tools
4837 .disabled
4838 .iter()
4839 .map(|name| name.trim())
4840 .filter(|name| !name.is_empty())
4841 .map(str::to_string)
4842 .collect()
4843 }
4844
4845 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4850 self.disabled_tool_references()
4851 .into_iter()
4852 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4853 .collect()
4854 }
4855
4856 pub fn normalize_tool_settings(&mut self) {
4858 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4859 }
4860
4861 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4863 self.skills
4864 .disabled
4865 .iter()
4866 .map(|id| id.trim())
4867 .filter(|id| !id.is_empty())
4868 .map(|id| id.to_string())
4869 .collect()
4870 }
4871
4872 pub fn normalize_skill_settings(&mut self) {
4874 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4875 }
4876
4877 pub fn normalize_plugin_trust_settings(&mut self) {
4885 self.plugin_trust.trusted_hosts = self
4886 .plugin_trust
4887 .trusted_hosts
4888 .iter()
4889 .map(|entry| entry.trim().to_ascii_lowercase())
4890 .filter(|entry| !entry.is_empty())
4891 .collect();
4892 }
4893
4894 pub fn effective_default_provider(&self) -> &str {
4899 self.default_provider_instance
4900 .as_deref()
4901 .unwrap_or(&self.provider)
4902 }
4903
4904 pub fn has_provider_instances(&self) -> bool {
4906 !self.provider_instances.is_empty()
4907 }
4908
4909 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4911 self.env_vars
4912 .iter()
4913 .filter(|e| !e.value.trim().is_empty())
4914 .map(|e| (e.name.clone(), e.value.clone()))
4915 .collect()
4916 }
4917
4918 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4919 self.env_vars
4920 .iter()
4921 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4922 .map(|entry| PromptSafeEnvVarEntry {
4923 name: entry.name.clone(),
4924 secret: entry.secret,
4925 description: entry
4926 .description
4927 .as_ref()
4928 .map(|value| value.trim().to_string())
4929 .filter(|value| !value.is_empty()),
4930 })
4931 .collect()
4932 }
4933
4934 pub fn publish_env_vars(&self) {
4936 let map = self.env_vars_as_map();
4937
4938 #[cfg(any(test, feature = "test-utils"))]
4939 if crate::test_support::env_vars_cache_override_is_active() {
4940 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4941 return;
4942 }
4943
4944 let mut env_guard = env_vars_cache().write().recover_poison();
4945 *env_guard = map;
4946
4947 let prompt_safe = self.prompt_safe_env_vars();
4948 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4949 *prompt_guard = prompt_safe;
4950 }
4951
4952 pub fn current_env_vars() -> HashMap<String, String> {
4954 #[cfg(any(test, feature = "test-utils"))]
4955 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4956 return env_vars;
4957 }
4958
4959 env_vars_cache().read().recover_poison().clone()
4960 }
4961
4962 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4964 #[cfg(any(test, feature = "test-utils"))]
4965 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4966 return env_vars;
4967 }
4968
4969 prompt_safe_env_vars_cache().read().recover_poison().clone()
4970 }
4971
4972 fn create_default() -> Self {
4974 Self::from_parts(
4975 ConfigValues {
4976 http_proxy: String::new(),
4977 https_proxy: String::new(),
4978 proxy_auth: None,
4979 proxy_auth_encrypted: None,
4980 proxy_auth_credential_ref: None,
4981 headless_auth: false,
4982 run_budget: RunBudgetConfig::default(),
4983 stream_timeout: StreamTimeoutConfig::default(),
4984 llm_retry: None,
4985 context_management: ContextManagementConfig::default(),
4986 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4987 provider: default_provider(),
4988 provider_instances: HashMap::new(),
4989 default_provider_instance: None,
4990 server: ServerConfig::default(),
4991 keyword_masking: KeywordMaskingConfig::default(),
4992 anthropic_model_mapping: AnthropicModelMapping::default(),
4993 gemini_model_mapping: GeminiModelMapping::default(),
4994 patrol_model: None,
4995 hooks: HooksConfig::default(),
4996 lifecycle_hooks: LifecycleHooksConfig::default(),
4997 tools: ToolsConfig::default(),
4998 skills: SkillsConfig::default(),
4999 env_vars: Vec::new(),
5000 default_work_area: None,
5001 worktrees: WorktreeSettings::default(),
5002 access_control: None,
5003 features: FeatureFlags::default(),
5004 defaults: None,
5005 mcp: bamboo_domain::mcp_config::McpConfig::default(),
5006 notifications: NotificationsConfig::default(),
5007 connect: ConnectConfig::default(),
5008 plugin_trust: PluginTrustConfig::default(),
5009 extra: BTreeMap::new(),
5010 },
5011 None,
5012 SubagentsConfig::default(),
5013 ProviderConfigs::default(),
5014 )
5015 }
5016
5017 pub fn server_addr(&self) -> String {
5019 format!("{}:{}", self.server.bind, self.server.port)
5020 }
5021
5022 pub fn save(&self) -> Result<()> {
5024 self.save_to_dir(default_data_dir())
5025 }
5026
5027 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5030 self.memory.save_sync(data_dir)
5031 }
5032
5033 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5036 self.subagents.save_sync(data_dir)
5037 }
5038
5039 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5043 crate::config_module::ensure_legacy_module_layout(data_dir)?;
5044 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5045 let mut config = self.clone();
5046 config.clear_legacy_provider_aliases_for_instance_mode();
5047 config.refresh_provider_api_keys_encrypted()?;
5048 config.providers.save_sync(data_dir)
5049 }
5050
5051 pub(crate) fn prepare_provider_transaction_documents(
5055 &self,
5056 provider_document: &[u8],
5057 ) -> Result<(Vec<u8>, Vec<u8>)> {
5058 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5059 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5060 anyhow::bail!(
5061 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5062 status.source
5063 );
5064 }
5065
5066 let mut to_save = self.clone();
5067 to_save.clear_legacy_provider_aliases_for_instance_mode();
5068 to_save.extra.remove("data_dir");
5069 to_save.extra.remove("model");
5070 to_save.refresh_encrypted_secrets()?;
5071 to_save.ensure_provider_instance_credentials_isolated()?;
5072 to_save.sanitize_mcp_credential_refs_for_disk();
5073 to_save.sanitize_env_vars_for_disk();
5074 to_save.sanitize_notifications_for_disk();
5075 to_save.sanitize_cluster_fabric_for_disk();
5076 to_save.assign_connect_platform_ids();
5077 to_save.normalize_tool_settings();
5078 to_save.normalize_skill_settings();
5079
5080 let mut root = durable_root_value(to_save.values.clone())
5081 .context("Failed to serialize root config DTO to JSON")?;
5082 if let Some(object) = root.as_object_mut() {
5083 object.remove("connect");
5084 }
5085 let root = serde_json::to_vec_pretty(&root)?;
5086
5087 let mut providers = to_save.providers.0.clone();
5088 macro_rules! sanitize_provider {
5089 ($field:ident) => {
5090 if let Some(provider) = providers.$field.as_mut() {
5091 if !provider.api_key.trim().is_empty()
5092 && !provider.api_key_from_env
5093 && provider.credential_ref.is_none()
5094 {
5095 anyhow::bail!(
5096 "provider secret requires credential transaction before persistence"
5097 );
5098 }
5099 provider.api_key_encrypted = None;
5100 }
5101 };
5102 }
5103 sanitize_provider!(openai);
5104 sanitize_provider!(anthropic);
5105 sanitize_provider!(gemini);
5106 if let Some(provider) = providers.bodhi.as_mut() {
5107 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5108 anyhow::bail!("provider secret requires credential transaction before persistence");
5109 }
5110 provider.api_key_encrypted = None;
5111 }
5112
5113 let existing_provider_value = if provider_document.is_empty() {
5114 None
5115 } else {
5116 Some(
5117 serde_json::from_slice::<Value>(provider_document)
5118 .context("provider metadata document is invalid")?,
5119 )
5120 };
5121 let provider_value = match existing_provider_value {
5122 Some(Value::Object(mut envelope))
5123 if envelope.contains_key("schema_version")
5124 || envelope.contains_key("revision")
5125 || envelope.contains_key("data") =>
5126 {
5127 let revision = envelope
5128 .get("revision")
5129 .and_then(Value::as_u64)
5130 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5131 let schema_version = envelope
5132 .get("schema_version")
5133 .and_then(Value::as_u64)
5134 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5135 if schema_version != 1 || !envelope.contains_key("data") {
5136 anyhow::bail!("provider revision envelope is unsupported");
5137 }
5138 let revision = revision
5139 .checked_add(1)
5140 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5141 envelope.insert("revision".into(), Value::from(revision));
5142 envelope.insert("data".into(), serde_json::to_value(providers)?);
5143 Value::Object(envelope)
5144 }
5145 Some(_) | None => serde_json::to_value(providers)?,
5146 };
5147 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5148 }
5149
5150 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5154 self.recovery_status.as_ref()
5155 }
5156
5157 pub fn confirm_recovery(&mut self) {
5163 if let Some(status) = self.recovery_status.as_mut() {
5164 status.confirmed = true;
5165 }
5166 }
5167
5168 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5176 if self.recovery_status.is_none() {
5177 anyhow::bail!("No pending config-corruption recovery to confirm");
5178 }
5179 self.confirm_recovery();
5180 self.save_to_dir(data_dir)?;
5181 self.recovery_status = None;
5182 Ok(())
5183 }
5184
5185 pub fn assign_connect_platform_ids(&mut self) {
5201 for platform in &mut self.connect.platforms {
5202 if platform.id.is_none() {
5203 platform.id = Some(uuid::Uuid::new_v4().to_string());
5204 }
5205 }
5206 }
5207
5208 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5220 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5221 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5222 anyhow::bail!(
5223 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5224 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5225 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5226 status.source,
5227 status.quarantine_path,
5228 );
5229 }
5230 if self.proxy_auth_credential_ref.is_none()
5231 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5232 {
5233 anyhow::bail!(
5234 "proxy auth requires the isolated credential transaction before persistence"
5235 );
5236 }
5237 if self.env_vars.iter().any(|entry| {
5238 entry.secret
5239 && entry.credential_ref.is_none()
5240 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5241 }) {
5242 anyhow::bail!(
5243 "secret env vars require the isolated credential transaction before persistence"
5244 );
5245 }
5246 if [
5247 (
5248 &self.notifications.ntfy.token,
5249 &self.notifications.ntfy.token_encrypted,
5250 &self.notifications.ntfy.credential_ref,
5251 ),
5252 (
5253 &self.notifications.bark.device_key,
5254 &self.notifications.bark.device_key_encrypted,
5255 &self.notifications.bark.credential_ref,
5256 ),
5257 ]
5258 .iter()
5259 .any(|(plaintext, ciphertext, reference)| {
5260 reference.is_none()
5261 && (plaintext
5262 .as_deref()
5263 .is_some_and(|value| !value.trim().is_empty())
5264 || ciphertext.is_some())
5265 }) {
5266 anyhow::bail!(
5267 "notification secrets require the isolated credential transaction before persistence"
5268 );
5269 }
5270
5271 if crate::modular_authority_boundary_present(&data_dir)
5272 .context("Failed to inspect modular configuration authority boundary")?
5273 {
5274 crate::ConfigFacade::open_or_migrate(&data_dir)
5275 .context("Failed to recover modular configuration before persistence")?;
5276 crate::persist_facade_effective_config(&data_dir, self)
5277 .context("Failed to persist modular configuration sections")?;
5278 return Ok(());
5279 }
5280
5281 if crate::section_layout_is_active(&data_dir)
5282 .context("Failed to inspect modular configuration layout")?
5283 {
5284 crate::persist_facade_effective_config(&data_dir, self)
5285 .context("Failed to persist modular configuration sections")?;
5286 return Ok(());
5287 }
5288
5289 let path = data_dir.join("config.json");
5290
5291 if let Some(parent) = path.parent() {
5292 std::fs::create_dir_all(parent)
5293 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5294 }
5295
5296 let mut to_save = self.clone();
5297 to_save.clear_legacy_provider_aliases_for_instance_mode();
5298 to_save.extra.remove("data_dir");
5300 to_save.extra.remove("model");
5302 to_save.refresh_encrypted_secrets()?;
5305 to_save.ensure_provider_instance_credentials_isolated()?;
5306 to_save.sanitize_mcp_credential_refs_for_disk();
5307 to_save.sanitize_env_vars_for_disk();
5308 to_save.sanitize_notifications_for_disk();
5309 to_save.sanitize_cluster_fabric_for_disk();
5310 to_save.assign_connect_platform_ids();
5311 to_save.normalize_tool_settings();
5312 to_save.normalize_skill_settings();
5313
5314 let mut config_value = durable_root_value(to_save.values.clone())
5324 .context("Failed to serialize root config DTO to JSON")?;
5325 if let Some(obj) = config_value.as_object_mut() {
5326 obj.remove("connect");
5327 }
5328 let content = serde_json::to_string_pretty(&config_value)
5329 .context("Failed to serialize config to JSON")?;
5330
5331 to_save.memory.save_sync(&data_dir)?;
5338 to_save.subagents.save_sync(&data_dir)?;
5339 to_save.providers.save_sync(&data_dir)?;
5340
5341 if path.exists()
5348 && std::fs::read_to_string(&path)
5349 .ok()
5350 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5351 {
5352 rotate_backups(&path, BAK_GENERATIONS);
5356 let backup = backup_path_for(&path, 0);
5357 let backup_result = std::fs::read(&path).and_then(|bytes| {
5358 let mut value: Value = serde_json::from_slice(&bytes)
5359 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5360 if sanitize_ref_backed_mcp_json(&mut value) {
5361 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5362 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5363 })?;
5364 write_atomic(&backup, &sanitized)
5365 } else {
5366 std::fs::copy(&path, &backup).map(|_| ())
5367 }
5368 });
5369 if let Err(e) = backup_result {
5370 tracing::warn!("Failed to back up config.json before save: {}", e);
5371 }
5372 scrub_ref_backed_mcp_from_config_backups(&path);
5373 }
5374
5375 write_atomic(&path, content.as_bytes())
5376 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5377
5378 save_connect_config(&to_save.connect, &data_dir)?;
5379
5380 Ok(())
5381 }
5382}
5383
5384fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5389 let Some(object) = root.as_object_mut() else {
5390 return false;
5391 };
5392 let Some(mcp) = (if object.contains_key("mcpServers") {
5393 object.get_mut("mcpServers")
5394 } else {
5395 object.get_mut("mcp")
5396 }) else {
5397 return false;
5398 };
5399 let mut changed = false;
5400 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5401 for server in servers {
5402 if let Some(object) = server.as_object_mut() {
5403 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5404 {
5405 changed |= sanitize_ref_backed_mcp_transport(transport);
5406 }
5407 }
5408 }
5409 } else if let Some(servers) = mcp.as_object_mut() {
5410 for server in servers.values_mut() {
5411 if let Some(object) = server.as_object_mut() {
5412 changed |= sanitize_ref_backed_mcp_transport(object);
5413 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5414 {
5415 changed |= sanitize_ref_backed_mcp_transport(transport);
5416 }
5417 }
5418 }
5419 }
5420 changed
5421}
5422
5423fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5424 let mut changed = false;
5425 let env_names = object
5426 .get("env_credential_refs")
5427 .and_then(Value::as_object)
5428 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5429 .unwrap_or_default();
5430 for name in env_names {
5431 for field in ["env", "env_encrypted"] {
5432 if object
5433 .get_mut(field)
5434 .and_then(Value::as_object_mut)
5435 .and_then(|values| values.remove(&name))
5436 .is_some()
5437 {
5438 changed = true;
5439 }
5440 }
5441 }
5442 let header_names = object
5443 .get("header_credential_refs")
5444 .and_then(Value::as_object)
5445 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5446 .unwrap_or_default();
5447 for name in header_names {
5448 for field in ["headers", "headers_encrypted"] {
5449 if object
5450 .get_mut(field)
5451 .and_then(Value::as_object_mut)
5452 .and_then(|values| values.remove(&name))
5453 .is_some()
5454 {
5455 changed = true;
5456 }
5457 }
5458 }
5459 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5460 for header in headers {
5461 let Some(header) = header.as_object_mut() else {
5462 continue;
5463 };
5464 if header.get("credential_ref").is_some_and(Value::is_string) {
5465 changed |= header.remove("value").is_some();
5466 changed |= header.remove("value_encrypted").is_some();
5467 }
5468 }
5469 }
5470 changed
5471}
5472
5473fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5474 for generation in 0..BAK_GENERATIONS {
5475 let backup = backup_path_for(path, generation);
5476 let Ok(bytes) = std::fs::read(&backup) else {
5477 continue;
5478 };
5479 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5480 continue;
5481 };
5482 if sanitize_ref_backed_mcp_json(&mut value) {
5483 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5484 if let Err(error) = write_atomic(&backup, &sanitized) {
5485 tracing::warn!(
5486 "Failed to scrub MCP credentials from {:?}: {}",
5487 backup,
5488 error
5489 );
5490 }
5491 }
5492 }
5493 }
5494}
5495
5496fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5506 let path = data_dir.join("connect.json");
5507 if connect_config_is_empty(connect) && !path.exists() {
5508 return Ok(());
5509 }
5510
5511 if path.exists() {
5512 let backup = path.with_extension("json.bak");
5513 if let Err(e) = std::fs::copy(&path, &backup) {
5514 tracing::warn!("Failed to back up connect.json before save: {}", e);
5515 }
5516 }
5517
5518 let content = serde_json::to_string_pretty(connect)
5519 .context("Failed to serialize connect config to JSON")?;
5520 write_atomic(&path, content.as_bytes())
5521 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5522 Ok(())
5523}
5524
5525fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5543 let config_path = data_dir.join("config.json");
5544 let content = match std::fs::read_to_string(&config_path) {
5545 Ok(content) => content,
5546 Err(e) => {
5547 tracing::error!(
5548 "Failed to read config.json to strip legacy `connect` key: {}",
5549 e
5550 );
5551 return;
5552 }
5553 };
5554 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5555 Ok(value) => value,
5556 Err(e) => {
5557 tracing::error!(
5558 "Failed to parse config.json to strip legacy `connect` key: {}",
5559 e
5560 );
5561 return;
5562 }
5563 };
5564 let Some(obj) = value.as_object_mut() else {
5565 return;
5566 };
5567 if obj.remove("connect").is_none() {
5568 return;
5571 }
5572 let rewritten = match serde_json::to_string_pretty(&value) {
5573 Ok(rewritten) => rewritten,
5574 Err(e) => {
5575 tracing::error!(
5576 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5577 e
5578 );
5579 return;
5580 }
5581 };
5582 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5583 tracing::error!(
5584 "Failed to write config.json after stripping legacy `connect` key: {}",
5585 e
5586 );
5587 }
5588}
5589
5590fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5627 let config_path = data_dir.join("config.json");
5628 for gen in 0..BAK_GENERATIONS {
5629 let backup = backup_path_for(&config_path, gen);
5630 let content = match std::fs::read_to_string(&backup) {
5631 Ok(content) => content,
5632 Err(e) => {
5633 if e.kind() != std::io::ErrorKind::NotFound {
5634 tracing::warn!(
5635 "Failed to read {:?} while scanning for legacy connect data ({}); \
5636 leaving it untouched",
5637 backup,
5638 e
5639 );
5640 }
5641 continue;
5642 }
5643 };
5644 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5645 Ok(value) => value,
5646 Err(e) => {
5647 tracing::warn!(
5648 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5649 ({}); left untouched (never deleted)",
5650 backup,
5651 e
5652 );
5653 continue;
5654 }
5655 };
5656 let Some(obj) = value.as_object_mut() else {
5657 continue;
5660 };
5661 if obj.remove("connect").is_none() {
5662 continue;
5665 }
5666 let rewritten = match serde_json::to_string_pretty(&value) {
5667 Ok(rewritten) => rewritten,
5668 Err(e) => {
5669 tracing::error!(
5670 "Failed to serialize {:?} after stripping legacy connect data: {}",
5671 backup,
5672 e
5673 );
5674 continue;
5675 }
5676 };
5677 match write_atomic(&backup, rewritten.as_bytes()) {
5678 Ok(()) => tracing::info!(
5679 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5680 backup
5681 ),
5682 Err(e) => tracing::error!(
5683 "Failed to write {:?} after stripping legacy connect data: {}",
5684 backup,
5685 e
5686 ),
5687 }
5688 }
5689}
5690
5691fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5706 let backup = connect_path.with_extension("json.bak");
5707 match std::fs::rename(connect_path, &backup) {
5708 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5709 Err(e) => {
5710 tracing::warn!(
5711 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5712 backup,
5713 e
5714 );
5715 if let Err(e) = std::fs::copy(connect_path, &backup) {
5716 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5717 return;
5718 }
5719 if let Err(e) = std::fs::remove_file(connect_path) {
5720 tracing::error!(
5721 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5722 original {:?}: {}",
5723 backup,
5724 connect_path,
5725 e
5726 );
5727 }
5728 }
5729 }
5730}
5731
5732const QUARANTINE_KEEP: usize = 5;
5735
5736fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5745 let nanos = std::time::SystemTime::now()
5746 .duration_since(std::time::UNIX_EPOCH)
5747 .map(|d| d.as_nanos())
5748 .unwrap_or(0);
5749 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5753 let mut dedup = 1u32;
5754 while quarantine.exists() {
5755 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5756 dedup += 1;
5757 }
5758 let result = match std::fs::copy(config_path, &quarantine) {
5759 Ok(_) => {
5760 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5761 Some(quarantine)
5762 }
5763 Err(e) => {
5764 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5765 None
5766 }
5767 };
5768 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5769 result
5770}
5771
5772fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5775 let Some(dir) = config_path.parent() else {
5776 return;
5777 };
5778 let prefix = "config.json.corrupted.";
5779 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5780 Ok(entries) => entries
5781 .filter_map(|e| e.ok())
5782 .map(|e| e.path())
5783 .filter(|p| {
5784 p.file_name()
5785 .and_then(|n| n.to_str())
5786 .is_some_and(|n| n.starts_with(prefix))
5787 })
5788 .collect(),
5789 Err(_) => return,
5790 };
5791 if quarantines.len() <= keep {
5792 return;
5793 }
5794 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5796 let remove = quarantines.len() - keep;
5797 for stale in quarantines.into_iter().take(remove) {
5798 if let Err(e) = std::fs::remove_file(&stale) {
5799 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5800 }
5801 }
5802}
5803
5804const BAK_GENERATIONS: usize = 3;
5807
5808fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5810 if gen == 0 {
5811 config_path.with_extension("json.bak")
5812 } else {
5813 config_path.with_extension(format!("json.bak.{gen}"))
5814 }
5815}
5816
5817fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5823 for gen in (1..generations).rev() {
5824 let from = backup_path_for(config_path, gen - 1);
5825 let to = backup_path_for(config_path, gen);
5826 if from.exists() {
5827 if let Err(e) = std::fs::rename(&from, &to) {
5828 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5829 }
5830 }
5831 }
5832}
5833
5834pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5835 crate::config_store::AtomicFileStore::new(path)
5836 .write_bytes_without_backup(content)
5837 .map_err(|error| match error {
5838 crate::config_store::ConfigStoreError::Io(error) => error,
5839 other => std::io::Error::other(other),
5840 })
5841}
5842
5843#[cfg(test)]
5844mod tests {
5845 use super::*;
5846 use std::ffi::OsString;
5847 use std::path::PathBuf;
5848 use std::sync::Mutex;
5849 use std::time::{SystemTime, UNIX_EPOCH};
5850
5851 #[test]
5852 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5853 let input = serde_json::json!({
5854 "disabled": ["bash"],
5855 "plugin_runtime": {
5856 "timeout_ms": 5_000,
5857 "sandbox": true
5858 },
5859 "future_flag": "enabled"
5860 });
5861
5862 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5863 assert_eq!(config.disabled, vec!["bash"]);
5864 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5865 assert_eq!(config.extra["future_flag"], "enabled");
5866 assert_eq!(serde_json::to_value(config).unwrap(), input);
5867 }
5868
5869 #[test]
5870 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5871 let input = serde_json::json!({
5872 "tool_extension": {
5873 "mode": "strict",
5874 "options": ["one", "two"]
5875 }
5876 });
5877 let config: Config = serde_json::from_value(serde_json::json!({
5878 "tools": input.clone()
5879 }))
5880 .unwrap();
5881
5882 assert!(config.tools.disabled.is_empty());
5883 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5884 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5885
5886 let temp_home = TempHome::new();
5887 config.save_to_dir(temp_home.path.clone()).unwrap();
5888 let persisted: Value =
5889 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5890 .unwrap();
5891 assert_eq!(persisted["tools"], input);
5892 }
5893
5894 #[test]
5895 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5896 let input = serde_json::json!({
5897 "external_catalog": {
5898 "path": "/opt/bamboo/skills",
5899 "refresh": false
5900 },
5901 "schema_version": 2
5902 });
5903
5904 let config: Config = serde_json::from_value(serde_json::json!({
5905 "skills": input.clone()
5906 }))
5907 .unwrap();
5908 assert!(config.skills.disabled.is_empty());
5909 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5910 assert_eq!(config.skills.extra["schema_version"], 2);
5911 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5912
5913 let temp_home = TempHome::new();
5914 config.save_to_dir(temp_home.path.clone()).unwrap();
5915 let persisted: Value =
5916 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5917 .unwrap();
5918 assert_eq!(persisted["skills"], input);
5919 }
5920
5921 #[test]
5922 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5923 let config: Config = serde_json::from_value(serde_json::json!({
5924 "hooks": {
5925 "image_fallback": {"enabled": true, "mode": "placeholder"}
5926 },
5927 "lifecycle_hooks": {
5928 "enabled": true,
5929 "PreToolUse": [{
5930 "matcher": "bash|write_file",
5931 "hooks": [{"type": "command", "command": "guard.sh"}]
5932 }],
5933 "SessionStart": [{
5934 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5935 }]
5936 }
5937 }))
5938 .expect("lifecycle hook config should deserialize");
5939
5940 assert!(config.lifecycle_hooks.enabled);
5941 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5942 assert!(
5943 config.lifecycle_hooks.pre_tool_use[0].enabled,
5944 "legacy groups without an enabled flag remain active"
5945 );
5946 assert_eq!(
5947 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5948 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5949 );
5950 assert_eq!(
5951 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5952 25
5953 );
5954
5955 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5956 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5957 assert_eq!(
5958 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5959 "bash|write_file"
5960 );
5961 assert_eq!(
5962 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5963 "command"
5964 );
5965 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5966 .get("enabled")
5967 .is_none());
5968 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5969 .get("timeout_ms")
5970 .is_none());
5971 assert!(json.get("hooks").is_some());
5972 }
5973
5974 #[test]
5975 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5976 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5977 "type": "script",
5978 "path": ".bamboo/hooks/check.js"
5979 }))
5980 .expect("script lifecycle hook should deserialize");
5981
5982 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5983 assert!(matches!(
5984 handler,
5985 LifecycleHookHandler::Script {
5986 runner: LifecycleScriptRunner::Auto,
5987 ..
5988 }
5989 ));
5990 let json = serde_json::to_value(handler).expect("script hook should serialize");
5991 assert_eq!(json["type"], "script");
5992 assert!(json.get("timeout_ms").is_none());
5993 assert!(json.get("runner").is_none());
5994 }
5995
5996 #[test]
5997 fn script_runner_support_is_extension_aware() {
5998 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5999 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
6000 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
6001 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
6002 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
6003 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
6004 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
6005 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
6006 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
6007 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
6008 }
6009
6010 #[test]
6011 fn script_runner_names_round_trip_through_config_json() {
6012 for (runner, name) in [
6013 (LifecycleScriptRunner::Auto, "auto"),
6014 (LifecycleScriptRunner::Node, "node"),
6015 (LifecycleScriptRunner::Bun, "bun"),
6016 (LifecycleScriptRunner::Python, "python"),
6017 (LifecycleScriptRunner::Bash, "bash"),
6018 (LifecycleScriptRunner::PowerShell, "powershell"),
6019 (LifecycleScriptRunner::Cmd, "cmd"),
6020 ] {
6021 let json = serde_json::to_value(runner).unwrap();
6022 assert_eq!(json, name);
6023 assert_eq!(
6024 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
6025 runner
6026 );
6027 }
6028 }
6029
6030 #[test]
6031 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6032 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6033 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6034 let json = serde_json::to_value(&config).unwrap();
6035 assert!(json.get("lifecycle_hooks").is_none());
6036 }
6037
6038 #[test]
6039 fn stream_timeout_defaults_are_safe_and_back_compatible() {
6040 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6041 .expect("legacy config without stream_timeout should load");
6042 let values = ConfigValues::from(root);
6043
6044 assert_eq!(
6045 values.stream_timeout,
6046 StreamTimeoutConfig {
6047 transport_idle_timeout_secs: 120,
6048 first_semantic_timeout_secs: 600,
6049 semantic_idle_timeout_secs: 600,
6050 }
6051 );
6052 values
6053 .stream_timeout
6054 .validate()
6055 .expect("defaults are valid");
6056 }
6057
6058 #[test]
6059 fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6060 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6061 .expect("legacy config without llm_retry should load");
6062 let values = ConfigValues::from(root);
6063 assert_eq!(values.llm_retry, None);
6066
6067 let defaults = LlmRetryConfig::default();
6068 assert_eq!(defaults.max_attempts, 8);
6069 assert_eq!(defaults.attempt_timeout_ms, 180_000);
6070 assert_eq!(defaults.total_budget_ms, 900_000);
6071 defaults.validate().expect("defaults are valid");
6072 }
6073
6074 #[test]
6075 fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6076 let valid = LlmRetryConfig::default();
6077 assert!(valid.validate().is_ok());
6078 for broken in [
6079 LlmRetryConfig {
6080 max_attempts: 0,
6081 ..valid
6082 },
6083 LlmRetryConfig {
6084 max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6085 ..valid
6086 },
6087 LlmRetryConfig {
6088 attempt_timeout_ms: 999,
6089 ..valid
6090 },
6091 LlmRetryConfig {
6092 attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6093 ..valid
6094 },
6095 LlmRetryConfig {
6096 total_budget_ms: 0,
6097 ..valid
6098 },
6099 LlmRetryConfig {
6100 total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6101 ..valid
6102 },
6103 LlmRetryConfig {
6105 base_delay_ms: 2_000,
6106 max_delay_ms: 1_000,
6107 ..valid
6108 },
6109 ] {
6110 assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6111 }
6112 }
6113
6114 #[test]
6115 fn llm_retry_round_trips_through_its_section() {
6116 let policy = LlmRetryConfig {
6117 max_attempts: 12,
6118 attempt_timeout_ms: 300_000,
6119 total_budget_ms: 3_600_000,
6120 base_delay_ms: 250,
6121 max_delay_ms: 60_000,
6122 };
6123 let values = ConfigValues {
6124 llm_retry: Some(policy),
6125 ..ConfigValues::default()
6126 };
6127 let json = serde_json::to_value(&values).expect("serialize");
6128 assert_eq!(json["llm_retry"]["max_attempts"], 12);
6129 let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6130 assert_eq!(round_tripped.llm_retry, Some(policy));
6131 }
6132
6133 #[test]
6134 fn stream_timeout_round_trips_through_persistence_dto() {
6135 let values = ConfigValues {
6136 stream_timeout: StreamTimeoutConfig {
6137 transport_idle_timeout_secs: 45,
6138 first_semantic_timeout_secs: 900,
6139 semantic_idle_timeout_secs: 300,
6140 },
6141 ..ConfigValues::default()
6142 };
6143
6144 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6145 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6146 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6147 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6148
6149 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6150 assert_eq!(
6151 ConfigValues::from(root).stream_timeout,
6152 StreamTimeoutConfig {
6153 transport_idle_timeout_secs: 45,
6154 first_semantic_timeout_secs: 900,
6155 semantic_idle_timeout_secs: 300,
6156 }
6157 );
6158 }
6159
6160 #[test]
6161 fn context_management_defaults_to_summary_and_is_omitted() {
6162 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6163 assert_eq!(
6164 config.context_management.strategy,
6165 ContextManagementStrategy::Summary
6166 );
6167 assert_eq!(
6168 config
6169 .context_management
6170 .retrieval_window
6171 .min_recent_user_turns,
6172 3
6173 );
6174 assert_eq!(
6175 config.context_management.retrieval_target_usage_percent(),
6176 25
6177 );
6178 let json = serde_json::to_value(&config).unwrap();
6179 assert!(json.get("context_management").is_none());
6180 }
6181
6182 #[test]
6183 fn retrieval_window_context_management_round_trips() {
6184 let json = serde_json::json!({
6185 "context_management": {
6186 "strategy": "retrieval_window",
6187 "retrieval_window": {
6188 "min_recent_user_turns": 4,
6189 "trigger_usage_ratio": 0.82,
6190 "target_usage_ratio": 0.61,
6191 "history_tool_required": true,
6192 "fallback_strategy": "summary"
6193 }
6194 }
6195 });
6196 let root: ConfigRoot = serde_json::from_value(json).unwrap();
6197 let values = ConfigValues::from(root);
6198 assert_eq!(
6199 values.context_management.strategy,
6200 ContextManagementStrategy::RetrievalWindow
6201 );
6202 assert_eq!(
6203 values.context_management.retrieval_window.fallback_strategy,
6204 ContextManagementFallbackStrategy::Summary
6205 );
6206
6207 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6208 assert_eq!(
6209 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6210 4
6211 );
6212 assert_eq!(
6213 persisted["context_management"]["strategy"],
6214 "retrieval_window"
6215 );
6216 }
6217
6218 #[test]
6219 fn message_summary_context_management_round_trips_without_changing_defaults() {
6220 let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6221 "context_management": {
6222 "strategy": "message_summary"
6223 }
6224 }))
6225 .unwrap();
6226 let values = ConfigValues::from(root);
6227 assert_eq!(
6228 values.context_management.strategy,
6229 ContextManagementStrategy::MessageSummary
6230 );
6231 assert_eq!(
6232 values.context_management.retrieval_window,
6233 RetrievalWindowContextConfig::default()
6234 );
6235
6236 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6237 assert_eq!(
6238 persisted["context_management"]["strategy"],
6239 "message_summary"
6240 );
6241 let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6242 assert_eq!(
6243 ConfigValues::from(reloaded).context_management.strategy,
6244 ContextManagementStrategy::MessageSummary
6245 );
6246 assert_eq!(
6247 ContextManagementConfig::default().strategy,
6248 ContextManagementStrategy::Summary
6249 );
6250 }
6251
6252 #[test]
6253 fn context_management_uses_one_explicit_policy_for_every_strategy() {
6254 for strategy in ["summary", "message_summary", "retrieval_window"] {
6255 let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6256 "strategy": strategy,
6257 "trigger_usage_ratio": 0.72,
6258 "target_usage_ratio": 0.20,
6259 "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6260 }))
6261 .unwrap();
6262 let budget =
6263 policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6264 assert_eq!(budget.compression_trigger_percent, 72);
6265 assert_eq!(budget.compression_target_percent, 20);
6266 let saved = serde_json::to_value(&policy).unwrap();
6267 let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6268 assert_eq!(restored, policy);
6269 }
6270 }
6271
6272 #[test]
6273 fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6274 ) {
6275 for strategy in ["summary", "message_summary", "retrieval_window"] {
6276 for (trigger, target, expected_trigger, expected_target) in
6277 [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6278 {
6279 let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6280 "strategy": strategy,
6281 "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6282 })).unwrap();
6283 assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6284 assert_eq!(policy.target_usage_ratio, expected_target);
6285 }
6286 }
6287 }
6288
6289 #[test]
6290 fn context_management_rejects_unrepresentable_shared_range() {
6291 for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6292 let value =
6293 serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6294 assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6295 }
6296 }
6297
6298 #[test]
6299 fn retrieval_window_context_management_rejects_unsafe_policy() {
6300 for invalid in [
6301 serde_json::json!({
6302 "strategy": "retrieval_window",
6303 "retrieval_window": {"min_recent_user_turns": 0}
6304 }),
6305 serde_json::json!({
6306 "strategy": "retrieval_window",
6307 "retrieval_window": {
6308 "target_usage_ratio": 0.8,
6309 "trigger_usage_ratio": 0.8
6310 }
6311 }),
6312 serde_json::json!({
6313 "strategy": "retrieval_window",
6314 "retrieval_window": {
6315 "target_usage_ratio": 0.005,
6316 "trigger_usage_ratio": 0.006
6317 }
6318 }),
6319 serde_json::json!({
6320 "strategy": "retrieval_window",
6321 "retrieval_window": {"history_tool_required": false}
6322 }),
6323 ] {
6324 serde_json::from_value::<ContextManagementConfig>(invalid)
6325 .expect_err("unsafe retrieval-window policy must fail closed");
6326 }
6327 }
6328
6329 #[test]
6330 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6331 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6332 "provider_type": "openai",
6333 "enabled": true
6334 }))
6335 .unwrap();
6336 let mut config = Config::default();
6337 config.values.provider = "gemini".to_string();
6338 config
6339 .provider_instances
6340 .insert("work".to_string(), instance);
6341 config.default_provider_instance = Some("work".to_string());
6342 config.providers_mut().openai = Some(OpenAIConfig::default());
6343
6344 let json = serde_json::to_value(&config).unwrap();
6345 assert_eq!(json["provider"], "gemini");
6346 assert!(json["providers"]["openai"].is_object());
6347 assert_eq!(json["default_provider_instance"], "work");
6348
6349 let round_trip: Config = serde_json::from_value(json).unwrap();
6350 assert_eq!(round_trip.provider, "gemini");
6351 assert_eq!(
6352 round_trip.default_provider_instance.as_deref(),
6353 Some("work")
6354 );
6355 assert!(round_trip.provider_instances.contains_key("work"));
6356 assert!(round_trip.providers().openai.is_some());
6357 }
6358
6359 #[test]
6360 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6361 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6362 let dir = tempfile::tempdir().unwrap();
6363 let mut config = Config::default();
6364 config.values.provider = "anthropic".to_string();
6365 config.provider_instances.insert(
6366 "work".to_string(),
6367 serde_json::from_value(serde_json::json!({
6368 "provider_type": "openai",
6369 "model": "gpt-instance",
6370 "enabled": true,
6371 "future_instance_metadata": "preserved"
6372 }))
6373 .unwrap(),
6374 );
6375 config.default_provider_instance = Some("work".to_string());
6376 config.features.provider_model_ref = true;
6377 config.providers_mut().openai = Some(OpenAIConfig::default());
6378 config.providers_mut().anthropic = Some(AnthropicConfig::default());
6379 config
6380 .providers_mut()
6381 .extra
6382 .insert("provider".to_string(), serde_json::json!("anthropic"));
6383 config.providers_mut().extra.insert(
6384 "future_provider".to_string(),
6385 serde_json::json!({"kept": true}),
6386 );
6387
6388 let (root_bytes, provider_bytes) =
6389 config.prepare_provider_transaction_documents(&[]).unwrap();
6390 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6391 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6392 assert!(root.get("provider").is_none());
6393 assert!(root.get("providers").is_none());
6394 assert_eq!(root["default_provider_instance"], "work");
6395 assert_eq!(
6396 root["provider_instances"]["work"]["future_instance_metadata"],
6397 "preserved"
6398 );
6399 assert!(providers.get("provider").is_none());
6400 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6401 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6402 }
6403 assert_eq!(providers["future_provider"]["kept"], true);
6404
6405 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6406 let saved_root: Value =
6407 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6408 .unwrap();
6409 let saved_providers: Value =
6410 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6411 .unwrap();
6412 assert!(saved_root.get("provider").is_none());
6413 assert!(saved_root.get("providers").is_none());
6414 assert_eq!(saved_root["default_provider_instance"], "work");
6415 assert!(saved_providers.get("openai").is_none());
6416 assert!(saved_providers.get("anthropic").is_none());
6417 assert!(saved_providers.get("provider").is_none());
6418 assert_eq!(saved_providers["future_provider"]["kept"], true);
6419
6420 config.providers_mut().gemini = Some(GeminiConfig::default());
6421 config.save_providers_to_dir(dir.path()).unwrap();
6422 let provider_only: Value =
6423 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6424 .unwrap();
6425 assert!(provider_only.get("gemini").is_none());
6426 assert!(provider_only.get("provider").is_none());
6427 assert_eq!(provider_only["future_provider"]["kept"], true);
6428 }
6429
6430 #[test]
6431 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6432 let mut config = Config::default();
6433 config.values.provider = "openai".to_string();
6434 config.providers_mut().openai = Some(OpenAIConfig {
6435 model: Some("gpt-legacy".to_string()),
6436 ..OpenAIConfig::default()
6437 });
6438 config.provider_instances.insert(
6439 "work".to_string(),
6440 serde_json::from_value(serde_json::json!({
6441 "provider_type": "copilot",
6442 "enabled": true
6443 }))
6444 .unwrap(),
6445 );
6446 config.default_provider_instance = Some("openai".to_string());
6447
6448 let (root_bytes, provider_bytes) =
6449 config.prepare_provider_transaction_documents(&[]).unwrap();
6450 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6451 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6452 assert_eq!(root["provider"], "openai");
6453 assert_eq!(root["default_provider_instance"], "openai");
6454 assert!(root["provider_instances"]["work"].is_object());
6455 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6456 }
6457
6458 #[test]
6459 fn persistence_keeps_legacy_provider_without_instance_default() {
6460 let values = ConfigValues {
6461 provider: "gemini".to_string(),
6462 ..ConfigValues::default()
6463 };
6464
6465 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6466 assert_eq!(json["provider"], "gemini");
6467 }
6468
6469 #[test]
6470 fn stream_timeout_rejects_zero_and_unbounded_values() {
6471 for (field, invalid) in [
6472 ("transport_idle_timeout_secs", 0),
6473 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6474 ("semantic_idle_timeout_secs", 0),
6475 ] {
6476 let mut timeout = serde_json::json!({});
6477 timeout[field] = serde_json::json!(invalid);
6478 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6479 .expect_err("invalid timeout must be rejected");
6480 assert!(error.to_string().contains("stream timeout must be between"));
6481 }
6482 }
6483
6484 struct EnvVarGuard {
6485 key: &'static str,
6486 previous: Option<OsString>,
6487 }
6488
6489 impl EnvVarGuard {
6490 fn set(key: &'static str, value: &str) -> Self {
6491 let previous = std::env::var_os(key);
6492 std::env::set_var(key, value);
6493 Self { key, previous }
6494 }
6495
6496 fn unset(key: &'static str) -> Self {
6497 let previous = std::env::var_os(key);
6498 std::env::remove_var(key);
6499 Self { key, previous }
6500 }
6501 }
6502
6503 impl Drop for EnvVarGuard {
6504 fn drop(&mut self) {
6505 match &self.previous {
6506 Some(value) => std::env::set_var(self.key, value),
6507 None => std::env::remove_var(self.key),
6508 }
6509 }
6510 }
6511
6512 #[test]
6513 fn run_budget_config_merge_is_tighten_only_per_field() {
6514 let config_default = RunBudgetConfig {
6515 max_total_tokens: Some(100_000),
6516 max_tool_calls: Some(500),
6517 max_subagents: Some(10),
6518 max_rounds: Some(200),
6519 };
6520
6521 assert_eq!(
6523 config_default.merged_with_override(None),
6524 config_default,
6525 "no override falls back to the config default entirely"
6526 );
6527
6528 let tighten_one = RunBudgetConfig {
6531 max_total_tokens: Some(5_000),
6532 max_tool_calls: None,
6533 max_subagents: None,
6534 max_rounds: None,
6535 };
6536 let merged = config_default.merged_with_override(Some(&tighten_one));
6537 assert_eq!(merged.max_total_tokens, Some(5_000));
6538 assert_eq!(merged.max_tool_calls, Some(500));
6539 assert_eq!(merged.max_subagents, Some(10));
6540 assert_eq!(merged.max_rounds, Some(200));
6541
6542 let loosen_attempt = RunBudgetConfig {
6545 max_total_tokens: Some(999_999_999),
6546 max_tool_calls: Some(10_000),
6547 max_subagents: Some(1_000),
6548 max_rounds: Some(100_000),
6549 };
6550 assert_eq!(
6551 config_default.merged_with_override(Some(&loosen_attempt)),
6552 config_default,
6553 "looser per-request values must be clamped to the config ceiling"
6554 );
6555
6556 let empty_override = RunBudgetConfig::default();
6560 assert_eq!(
6561 config_default.merged_with_override(Some(&empty_override)),
6562 config_default,
6563 "an all-absent override body keeps every configured ceiling"
6564 );
6565
6566 let unlimited_default = RunBudgetConfig::default();
6570 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6571 assert_eq!(merged.max_total_tokens, Some(5_000));
6572 assert_eq!(merged.max_tool_calls, None);
6573 assert_eq!(merged.max_subagents, None);
6574 assert_eq!(merged.max_rounds, None);
6575
6576 let tighten_rounds = RunBudgetConfig {
6579 max_total_tokens: None,
6580 max_tool_calls: None,
6581 max_subagents: None,
6582 max_rounds: Some(20),
6583 };
6584 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6585 assert_eq!(merged.max_rounds, Some(20));
6586 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6587 assert_eq!(
6588 merged.max_rounds,
6589 Some(20),
6590 "a stricter per-request round cap wins over the config default"
6591 );
6592 }
6593
6594 #[test]
6595 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6596 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6597 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6598 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6599 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6600
6601 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6602 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6603 assert_eq!(cfg.max_total_tokens, Some(250_000));
6604 assert_eq!(
6605 cfg.max_tool_calls, None,
6606 "absent field defaults to unlimited"
6607 );
6608 assert_eq!(
6609 cfg.max_rounds, None,
6610 "absent round cap defaults to unlimited"
6611 );
6612
6613 assert_eq!(cfg.max_subagents, Some(3));
6614
6615 let json = r#"{ "max_rounds": 250 }"#;
6617 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6618 assert_eq!(cfg.max_rounds, Some(250));
6619 let round_tripped: RunBudgetConfig =
6620 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6621 assert_eq!(round_tripped.max_rounds, Some(250));
6622 assert_eq!(round_tripped.max_total_tokens, None);
6623
6624 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6628 assert_eq!(empty, "{}");
6629 }
6630
6631 #[test]
6632 fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6633 let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6634 assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6635 assert!(!serde_json::to_string(&old)
6636 .unwrap()
6637 .contains("max_spawn_depth"));
6638 for depth in [0, 1, 8] {
6639 let config: SubagentsConfig =
6640 serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6641 assert_eq!(config.effective_max_spawn_depth(), depth);
6642 assert_eq!(
6643 serde_json::to_value(config).unwrap()["max_spawn_depth"],
6644 depth
6645 );
6646 }
6647 for invalid in [
6648 serde_json::json!(-1),
6649 serde_json::json!(1.5),
6650 serde_json::json!("4"),
6651 ] {
6652 assert!(serde_json::from_value::<SubagentsConfig>(
6653 serde_json::json!({"max_spawn_depth": invalid}),
6654 )
6655 .is_err());
6656 }
6657 }
6658
6659 #[test]
6660 fn subagents_config_without_remote_placements_deserializes_empty() {
6661 let json = r#"{ "max_concurrent": 4 }"#;
6664 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6665 assert_eq!(cfg.max_concurrent, Some(4));
6666 assert!(cfg.remote_placements.is_empty());
6667 let back = serde_json::to_string(&cfg).unwrap();
6669 assert!(
6670 !back.contains("remote_placements"),
6671 "empty vec is skipped: {back}"
6672 );
6673 }
6674
6675 #[test]
6676 fn remote_actor_placement_round_trips() {
6677 let json = r#"{
6678 "remote_placements": [
6679 {
6680 "role": "explorer",
6681 "endpoint": "wss://gpu-host:8443",
6682 "token_env": "WORKER_TOKEN",
6683 "ca_cert_file": "/etc/bamboo/worker.pem"
6684 },
6685 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6686 ]
6687 }"#;
6688 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6689 assert_eq!(cfg.remote_placements.len(), 2);
6690 let p0 = &cfg.remote_placements[0];
6691 assert_eq!(p0.role, "explorer");
6692 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6693 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6694 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6695 let p1 = &cfg.remote_placements[1];
6697 assert_eq!(p1.role, "writer");
6698 assert!(p1.token_env.is_none());
6699 assert!(p1.ca_cert_file.is_none());
6700
6701 let back = serde_json::to_string(&cfg).unwrap();
6702 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6703 assert_eq!(cfg, reparsed, "round-trip is stable");
6704 assert!(!back.contains("\"token_env\":null"));
6705 assert!(!back.contains("\"ca_cert_file\":null"));
6706 }
6707
6708 #[test]
6709 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6710 let json = r#"{ "max_concurrent": 4 }"#;
6713 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6714 assert!(cfg.schedulable_placements.is_empty());
6715 let back = serde_json::to_string(&cfg).unwrap();
6717 assert!(
6718 !back.contains("schedulable_placements"),
6719 "empty vec is skipped: {back}"
6720 );
6721 }
6722
6723 #[test]
6724 fn schedulable_placement_round_trips() {
6725 let json = r#"{
6726 "schedulable_placements": [
6727 {
6728 "role": "explorer",
6729 "pool": "gpu-pool",
6730 "registry_url": "https://control-plane:9562",
6731 "token_env": "WORKER_TOKEN",
6732 "ca_cert_file": "/etc/bamboo/worker.pem"
6733 },
6734 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6735 ]
6736 }"#;
6737 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6738 assert_eq!(cfg.schedulable_placements.len(), 2);
6739 let p0 = &cfg.schedulable_placements[0];
6740 assert_eq!(p0.role, "explorer");
6741 assert_eq!(p0.pool, "gpu-pool");
6742 assert_eq!(p0.registry_url, "https://control-plane:9562");
6743 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6744 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6745 let p1 = &cfg.schedulable_placements[1];
6747 assert_eq!(p1.role, "writer");
6748 assert_eq!(p1.pool, "cpu-pool");
6749 assert!(p1.token_env.is_none());
6750 assert!(p1.ca_cert_file.is_none());
6751
6752 let back = serde_json::to_string(&cfg).unwrap();
6753 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6754 assert_eq!(cfg, reparsed, "round-trip is stable");
6755 assert!(!back.contains("\"token_env\":null"));
6756 assert!(!back.contains("\"ca_cert_file\":null"));
6757 }
6758
6759 #[test]
6760 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6761 let json = r#"{ "max_concurrent": 4 }"#;
6765 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6766 assert!(cfg.mcp_role_allowlist.is_empty());
6767 let back = serde_json::to_string(&cfg).unwrap();
6769 assert!(
6770 !back.contains("mcp_role_allowlist"),
6771 "empty vec is skipped: {back}"
6772 );
6773 }
6774
6775 #[test]
6776 fn mcp_role_allowlist_entry_round_trips() {
6777 let json = r#"{
6778 "mcp_role_allowlist": [
6779 { "role": "researcher", "tools": ["fetch_url"] },
6780 { "role": "sandboxed", "tools": [] }
6781 ]
6782 }"#;
6783 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6784 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6785 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6786 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6787 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6790 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6791
6792 let back = serde_json::to_string(&cfg).unwrap();
6793 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6794 assert_eq!(cfg, reparsed, "round-trip is stable");
6795 }
6796
6797 #[test]
6798 fn server_config_without_tls_field_deserializes_back_compat() {
6799 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6802 "port": 9562,
6803 "bind": "127.0.0.1"
6804 }))
6805 .expect("legacy server config without tls should deserialize");
6806
6807 assert_eq!(server.tls, None);
6808 assert_eq!(server.port, 9562);
6809 assert_eq!(server.bind, "127.0.0.1");
6810 }
6811
6812 #[test]
6813 fn server_config_omits_tls_when_none() {
6814 let server = ServerConfig::default();
6817 let value = serde_json::to_value(&server).expect("server config should serialize");
6818 let obj = value
6819 .as_object()
6820 .expect("server config serializes to object");
6821 assert!(
6822 !obj.contains_key("tls"),
6823 "tls must be omitted when None, got: {value}"
6824 );
6825 }
6826
6827 #[test]
6828 fn server_config_with_tls_roundtrips() {
6829 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6830 "port": 9562,
6831 "bind": "0.0.0.0",
6832 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6833 }))
6834 .expect("server config with tls should deserialize");
6835
6836 let tls = server.tls.clone().expect("tls should be Some");
6837 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6838 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6839
6840 let value = serde_json::to_value(&server).expect("serialize");
6842 assert!(value.as_object().unwrap().contains_key("tls"));
6843 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6844 assert_eq!(back.tls, server.tls);
6845 }
6846
6847 #[test]
6848 fn access_control_without_devices_field_deserializes_back_compat() {
6849 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6852 "password_enabled": true,
6853 "password_hash": "deadbeef",
6854 "password_salt": "01020304",
6855 }))
6856 .expect("legacy access_control without devices should deserialize");
6857
6858 assert!(access.devices.is_empty());
6859 assert!(access.password_enabled);
6860 }
6861
6862 #[test]
6863 fn access_control_omits_devices_when_empty() {
6864 let access = AccessControlConfig {
6867 password_enabled: true,
6868 repair_required: false,
6869 password_hash: Some("deadbeef".to_string()),
6870 password_salt: Some("01020304".to_string()),
6871 password_credential_ref: None,
6872 password_configured: false,
6873 updated_at: None,
6874 devices: Vec::new(),
6875 };
6876 let value = serde_json::to_value(&access).expect("serialize");
6877 let obj = value.as_object().expect("object");
6878 assert!(
6879 !obj.contains_key("devices"),
6880 "devices must be omitted when empty, got: {value}"
6881 );
6882 }
6883
6884 #[test]
6885 fn access_control_with_devices_roundtrips() {
6886 let device = DeviceCredential {
6887 device_id: "bamboo_0123456789ab".to_string(),
6888 label: "iPhone 15".to_string(),
6889 token_hash: "abcd".to_string(),
6890 token_salt: "ef01".to_string(),
6891 token_credential_ref: None,
6892 token_configured: false,
6893 created_at: "2026-06-23T00:00:00Z".to_string(),
6894 last_used_at: None,
6895 revoked: false,
6896 };
6897 let access = AccessControlConfig {
6898 password_enabled: true,
6899 repair_required: false,
6900 password_hash: Some("deadbeef".to_string()),
6901 password_salt: Some("01020304".to_string()),
6902 password_credential_ref: None,
6903 password_configured: false,
6904 updated_at: None,
6905 devices: vec![device.clone()],
6906 };
6907 let value = serde_json::to_value(&access).expect("serialize");
6908 assert!(value.as_object().unwrap().contains_key("devices"));
6909 assert!(value["devices"][0].get("token_hash").is_none());
6910 assert!(value["devices"][0].get("token_salt").is_none());
6911 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6912 assert_eq!(back.devices[0].device_id, device.device_id);
6913 assert!(back.devices[0].token_hash.is_empty());
6914 assert!(back.devices[0].token_salt.is_empty());
6915 }
6916
6917 #[test]
6918 fn reasoning_effort_for_key_resolves_instance_id() {
6919 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6923 "provider_type": "copilot",
6924 "reasoning_effort": "high",
6925 }))
6926 .expect("instance config should deserialize");
6927
6928 let mut config = Config::create_default();
6929 config
6930 .provider_instances
6931 .insert("copilot-work".to_string(), instance);
6932
6933 assert_eq!(
6934 config.reasoning_effort_for_key("copilot-work"),
6935 Some(ReasoningEffort::High),
6936 );
6937 }
6938
6939 #[test]
6940 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6941 let mut config = Config::create_default();
6943 config.providers.bodhi = Some(
6944 serde_json::from_value(serde_json::json!({
6945 "reasoning_effort": "xhigh",
6946 }))
6947 .expect("bodhi config should deserialize"),
6948 );
6949
6950 assert_eq!(
6951 config.reasoning_effort_for_key("bodhi"),
6952 Some(ReasoningEffort::Xhigh),
6953 );
6954 }
6955
6956 #[test]
6957 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6958 let mut config = Config::create_default();
6959 config.providers.openai = Some(
6960 serde_json::from_value(serde_json::json!({
6961 "api_key": "sk-test",
6962 "reasoning_effort": "low",
6963 }))
6964 .expect("openai config should deserialize"),
6965 );
6966
6967 assert_eq!(
6968 config.reasoning_effort_for_key("openai"),
6969 Some(ReasoningEffort::Low),
6970 );
6971 }
6972
6973 #[test]
6974 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6975 let config = Config::create_default();
6976 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6977 assert_eq!(config.reasoning_effort_for_key(" "), None);
6978 }
6979
6980 struct TempHome {
6981 path: PathBuf,
6982 }
6983
6984 impl TempHome {
6985 fn new() -> Self {
6986 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6998 std::sync::atomic::AtomicU64::new(0);
6999 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7000 let nanos = SystemTime::now()
7001 .duration_since(UNIX_EPOCH)
7002 .expect("clock should be after unix epoch")
7003 .as_nanos();
7004 let path = std::env::temp_dir().join(format!(
7005 "chat-core-config-test-{}-{}-{}",
7006 std::process::id(),
7007 nanos,
7008 unique
7009 ));
7010 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
7011 Self { path }
7012 }
7013
7014 fn set_config_json(&self, content: &str) {
7015 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
7018 std::fs::write(self.path.join("config.json"), content)
7019 .expect("failed to write config.json");
7020 }
7021 }
7022
7023 impl Drop for TempHome {
7024 fn drop(&mut self) {
7025 let _ = std::fs::remove_dir_all(&self.path);
7026 }
7027 }
7028
7029 fn env_lock() -> &'static Mutex<()> {
7033 crate::test_support::env_cache_lock()
7034 }
7035
7036 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7038 env_lock().lock().unwrap_or_else(|poisoned| {
7039 poisoned.into_inner()
7041 })
7042 }
7043
7044 #[test]
7045 fn parse_bool_env_true_values() {
7046 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7047 assert!(parse_bool_env(value), "value {value:?} should be true");
7048 }
7049 }
7050
7051 #[test]
7052 fn parse_bool_env_false_values() {
7053 for value in ["0", "false", "no", "off", "", " "] {
7054 assert!(!parse_bool_env(value), "value {value:?} should be false");
7055 }
7056 }
7057
7058 #[test]
7059 fn config_new_ignores_http_proxy_env_vars() {
7060 let _lock = env_lock_acquire();
7061 let temp_home = TempHome::new();
7062 temp_home.set_config_json(
7063 r#"{
7064 "http_proxy": "",
7065 "https_proxy": ""
7066}"#,
7067 );
7068
7069 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7070 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7071
7072 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7073
7074 assert!(
7075 config.http_proxy.is_empty(),
7076 "config should ignore HTTP_PROXY env var"
7077 );
7078 assert!(
7079 config.https_proxy.is_empty(),
7080 "config should ignore HTTPS_PROXY env var"
7081 );
7082 }
7083
7084 #[test]
7085 fn config_new_loads_config_when_proxy_fields_omitted() {
7086 let _lock = env_lock_acquire();
7087 let temp_home = TempHome::new();
7088 temp_home.set_config_json(
7089 r#"{
7090 "provider": "openai",
7091 "providers": {
7092 "openai": {
7093 "api_key": "sk-test",
7094 "model": "gpt-4o"
7095 }
7096 }
7097}"#,
7098 );
7099
7100 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7101 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7102
7103 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7104
7105 assert_eq!(
7106 config
7107 .providers
7108 .openai
7109 .as_ref()
7110 .and_then(|c| c.model.as_deref()),
7111 Some("gpt-4o"),
7112 "config should load provider model from config file even when proxy fields are omitted"
7113 );
7114 assert!(config.http_proxy.is_empty());
7115 assert!(config.https_proxy.is_empty());
7116 }
7117
7118 #[test]
7119 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7120 let _lock = crate::test_support::env_cache_lock_acquire();
7121 let mut config = Config::default();
7122 config.env_vars.extend([
7123 EnvVarEntry {
7124 name: "SECRET_TOKEN".to_string(),
7125 value: "top-secret".to_string(),
7126 secret: true,
7127 value_encrypted: None,
7128 credential_ref: None,
7129 configured: true,
7130 description: Some("Service token".to_string()),
7131 },
7132 EnvVarEntry {
7133 name: "API_BASE".to_string(),
7134 value: "https://internal.example".to_string(),
7135 secret: false,
7136 value_encrypted: None,
7137 credential_ref: None,
7138 configured: true,
7139 description: Some("Internal API base".to_string()),
7140 },
7141 ]);
7142
7143 config.publish_env_vars();
7144
7145 let injected = Config::current_env_vars();
7146 assert_eq!(
7147 injected.get("SECRET_TOKEN").map(String::as_str),
7148 Some("top-secret")
7149 );
7150 assert_eq!(
7151 injected.get("API_BASE").map(String::as_str),
7152 Some("https://internal.example")
7153 );
7154
7155 let prompt_safe = Config::current_prompt_safe_env_vars();
7156 assert_eq!(prompt_safe.len(), 2);
7157 assert!(prompt_safe.iter().any(|entry| {
7158 entry.name == "SECRET_TOKEN"
7159 && entry.secret
7160 && entry.description.as_deref() == Some("Service token")
7161 }));
7162 assert!(prompt_safe.iter().any(|entry| {
7163 entry.name == "API_BASE"
7164 && !entry.secret
7165 && entry.description.as_deref() == Some("Internal API base")
7166 }));
7167 assert!(!prompt_safe
7168 .iter()
7169 .any(|entry| entry.name.contains("top-secret")));
7170 assert!(!prompt_safe.iter().any(|entry| {
7171 entry
7172 .description
7173 .as_deref()
7174 .is_some_and(|value| value.contains("https://internal.example"))
7175 }));
7176 }
7177
7178 #[test]
7179 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7180 let _lock = crate::test_support::env_cache_lock_acquire();
7181
7182 let mut live = Config::default();
7184 live.env_vars.extend([EnvVarEntry {
7185 name: "BAMBOO_CACHE_OWNER_40".to_string(),
7186 value: "live".to_string(),
7187 secret: false,
7188 value_encrypted: None,
7189 credential_ref: None,
7190 configured: true,
7191 description: None,
7192 }]);
7193 live.publish_env_vars();
7194 assert_eq!(
7195 Config::current_env_vars()
7196 .get("BAMBOO_CACHE_OWNER_40")
7197 .map(String::as_str),
7198 Some("live")
7199 );
7200
7201 let temp = TempHome::new();
7203 temp.set_config_json(
7204 &serde_json::json!({
7205 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7206 })
7207 .to_string(),
7208 );
7209
7210 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7213 assert_eq!(
7214 loaded
7215 .env_vars
7216 .iter()
7217 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7218 .map(|e| e.value.as_str()),
7219 Some("stale-disk"),
7220 "the returned Config holds the disk value"
7221 );
7222 assert_eq!(
7223 Config::current_env_vars()
7224 .get("BAMBOO_CACHE_OWNER_40")
7225 .map(String::as_str),
7226 Some("live"),
7227 "but the global cache is UNTOUCHED — no clobber (#40)"
7228 );
7229
7230 let _ = Config::from_data_dir(Some(temp.path.clone()));
7232 assert_eq!(
7233 Config::current_env_vars()
7234 .get("BAMBOO_CACHE_OWNER_40")
7235 .map(String::as_str),
7236 Some("stale-disk"),
7237 "the publishing loader clobbers the cache (contrast)"
7238 );
7239 }
7240
7241 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7242 std::fs::read_dir(dir)
7243 .unwrap()
7244 .filter_map(|e| e.ok())
7245 .any(|e| {
7246 e.file_name()
7247 .to_string_lossy()
7248 .contains("config.json.corrupted.")
7249 })
7250 }
7251
7252 #[test]
7253 fn corrupt_config_recovered_from_backup_and_quarantined() {
7254 let temp = TempHome::new();
7255 std::fs::write(
7257 temp.path.join("config.json.bak"),
7258 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7259 )
7260 .unwrap();
7261 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7263
7264 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7265 assert_eq!(
7266 config.http_proxy, "http://from-backup",
7267 "recovered from config.json.bak instead of losing all config"
7268 );
7269 assert!(
7270 dir_has_quarantine_file(&temp.path),
7271 "corrupt config.json was quarantined (preserved), not discarded"
7272 );
7273 }
7274
7275 #[test]
7276 fn corrupt_config_without_backup_quarantines_then_defaults() {
7277 let temp = TempHome::new();
7278 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7279
7280 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7281 assert!(
7282 config.http_proxy.is_empty(),
7283 "no backup -> falls back to defaults"
7284 );
7285 assert!(
7286 dir_has_quarantine_file(&temp.path),
7287 "corrupt config.json is quarantined even when there's no backup"
7288 );
7289 }
7290
7291 #[test]
7292 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7293 let temp = TempHome::new();
7294 temp.set_config_json(
7299 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7300 );
7301
7302 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7303 assert_eq!(
7304 config.http_proxy, "http://salvaged",
7305 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7306 );
7307 assert!(
7308 config.env_vars.is_empty(),
7309 "the corrupt field reset to its default instead of failing the whole load"
7310 );
7311 assert!(
7312 dir_has_quarantine_file(&temp.path),
7313 "the corrupt config.json was still quarantined for inspection"
7314 );
7315 }
7316
7317 #[test]
7318 fn salvage_preferred_over_backup_for_most_recent_intent() {
7319 let temp = TempHome::new();
7320 std::fs::write(
7322 temp.path.join("config.json.bak"),
7323 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7324 )
7325 .unwrap();
7326 temp.set_config_json(
7328 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7329 );
7330
7331 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7332 assert_eq!(
7333 config.http_proxy, "http://new-salvaged",
7334 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7335 );
7336 }
7337
7338 #[test]
7339 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7340 let temp = TempHome::new();
7341 std::fs::write(
7343 temp.path.join("config.json.bak"),
7344 serde_json::json!({
7345 "http_proxy": "http://old-from-backup",
7346 "https_proxy": "https://kept-from-backup",
7347 })
7348 .to_string(),
7349 )
7350 .unwrap();
7351 temp.set_config_json(
7354 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7355 );
7356
7357 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7358 assert_eq!(
7360 config.http_proxy, "http://newer-edit",
7361 "the corrupt file's newer valid edit is applied"
7362 );
7363 assert_eq!(
7365 config.https_proxy, "https://kept-from-backup",
7366 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7367 );
7368 }
7369
7370 #[test]
7371 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7372 let temp = TempHome::new();
7373 std::fs::write(
7374 temp.path.join("config.json.bak"),
7375 serde_json::json!({
7376 "providers": {
7377 "anthropic": { "model": "claude-backup" }
7378 },
7379 "memory": {
7380 "auto_dream_enabled": true
7381 },
7382 "subagents": {
7383 "claude_code_model": "claude-code-backup"
7384 }
7385 })
7386 .to_string(),
7387 )
7388 .unwrap();
7389 assert!(!temp.path.join("providers.json").exists());
7390 assert!(!temp.path.join("memory.json").exists());
7391 assert!(!temp.path.join("subagents.json").exists());
7392
7393 let (salvaged, recovered_fields) = Config::salvage_partial(
7394 r#"{"providers":"schema-invalid-provider-section"}"#,
7395 &temp.path,
7396 )
7397 .expect("object-shaped corrupt config should be salvageable");
7398
7399 assert!(
7400 recovered_fields.is_empty(),
7401 "the schema-invalid provider field must not replace the backup baseline"
7402 );
7403 assert_eq!(
7404 salvaged
7405 .providers()
7406 .anthropic
7407 .as_ref()
7408 .and_then(|provider| provider.model.as_deref()),
7409 Some("claude-backup")
7410 );
7411 assert!(
7412 salvaged
7413 .memory()
7414 .as_ref()
7415 .expect("backup memory config survives")
7416 .auto_dream_enabled
7417 );
7418 assert_eq!(
7419 salvaged.subagents().claude_code_model.as_deref(),
7420 Some("claude-code-backup")
7421 );
7422 }
7423
7424 #[test]
7425 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7426 let temp = TempHome::new();
7427 std::fs::write(
7430 temp.path.join("config.json.bak"),
7431 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7432 )
7433 .unwrap();
7434 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7435
7436 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7437 assert_eq!(
7438 config.http_proxy, "http://from-backup",
7439 "garbage (non-object) config skips salvage and recovers from .bak"
7440 );
7441 }
7442
7443 #[test]
7444 fn quarantine_files_are_capped_to_newest_n() {
7445 let temp = TempHome::new();
7446 let config_path = temp.path.join("config.json");
7447 std::fs::write(&config_path, "{}").unwrap();
7448
7449 for _ in 0..(QUARANTINE_KEEP + 3) {
7452 quarantine_corrupt_config(&config_path);
7453 std::thread::sleep(std::time::Duration::from_millis(3));
7454 }
7455
7456 let count = std::fs::read_dir(&temp.path)
7457 .unwrap()
7458 .filter_map(|e| e.ok())
7459 .filter(|e| {
7460 e.file_name()
7461 .to_string_lossy()
7462 .starts_with("config.json.corrupted.")
7463 })
7464 .count();
7465 assert_eq!(
7466 count, QUARANTINE_KEEP,
7467 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7468 );
7469 }
7470
7471 #[test]
7472 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7473 let temp = TempHome::new();
7474 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7476 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7477 std::fs::write(
7478 temp.path.join("config.json.bak.1"),
7479 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7480 )
7481 .unwrap();
7482
7483 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7484 assert_eq!(
7485 config.http_proxy, "http://from-gen-1",
7486 "recovered from .bak.1 when both config.json and .bak are corrupt"
7487 );
7488 }
7489
7490 #[test]
7491 fn save_rotates_backup_generations() {
7492 let temp = TempHome::new();
7493 let path = temp.path.join("config.json");
7494 std::fs::write(
7496 &path,
7497 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7498 )
7499 .unwrap();
7500
7501 let mut cfg = Config::create_default();
7502 cfg.http_proxy = "http://proxy-v2".to_string();
7504 cfg.save_to_dir(temp.path.clone()).unwrap();
7505 cfg.http_proxy = "http://proxy-v3".to_string();
7507 cfg.save_to_dir(temp.path.clone()).unwrap();
7508
7509 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7510 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7511 assert!(
7512 bak.contains("proxy-v2"),
7513 ".bak holds the previous generation (v2)"
7514 );
7515 assert!(
7516 bak1.contains("proxy-v1"),
7517 ".bak.1 holds the older rotated generation (v1)"
7518 );
7519 }
7520
7521 #[test]
7522 fn save_backs_up_existing_config() {
7523 let temp = TempHome::new();
7524 std::fs::write(
7526 temp.path.join("config.json"),
7527 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7528 )
7529 .unwrap();
7530
7531 let mut config = Config::create_default();
7532 config.http_proxy = "http://new".to_string();
7533 config
7534 .save_to_dir(temp.path.clone())
7535 .expect("save succeeds");
7536
7537 let backup =
7538 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7539 assert!(
7540 backup.contains("http://old"),
7541 "config.json.bak holds the PREVIOUS config (last-known-good)"
7542 );
7543 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7544 assert!(
7545 current.contains("http://new"),
7546 "config.json holds the new config"
7547 );
7548 }
7549
7550 #[test]
7551 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7552 let temp = TempHome::new();
7553 std::fs::write(
7555 temp.path.join("config.json.bak"),
7556 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7557 )
7558 .unwrap();
7559 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7562
7563 let mut config = Config::create_default();
7564 config.http_proxy = "http://new".to_string();
7565 config
7566 .save_to_dir(temp.path.clone())
7567 .expect("save succeeds");
7568
7569 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7571 assert!(
7572 backup.contains("http://good-bak"),
7573 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7574 );
7575 }
7576
7577 #[test]
7580 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7581 let temp = TempHome::new();
7582 std::fs::write(
7583 temp.path.join("config.json.bak"),
7584 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7585 )
7586 .unwrap();
7587 let corrupt_bytes = "{ not valid json ";
7588 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7589
7590 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7591 let status = config
7592 .recovery_status()
7593 .expect("a corrupt load must set a pending recovery status");
7594 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7595 assert_eq!(
7596 status.source,
7597 ConfigRecoverySource::Backup { generation: 0 },
7598 "recovered from generation-0 (.bak)"
7599 );
7600 let quarantine_path = status
7601 .quarantine_path
7602 .as_ref()
7603 .expect("quarantine copy should have succeeded");
7604 assert_eq!(
7605 std::fs::read_to_string(quarantine_path).unwrap(),
7606 corrupt_bytes,
7607 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7608 );
7609 assert_eq!(
7610 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7611 corrupt_bytes,
7612 "the original config.json itself is untouched by the load (only copied, not moved)"
7613 );
7614 }
7615
7616 #[test]
7617 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7618 let temp = TempHome::new();
7619 temp.set_config_json(
7620 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7621 );
7622
7623 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7624 let status = config.recovery_status().expect("pending recovery");
7625 assert!(!status.confirmed);
7626 match &status.source {
7627 ConfigRecoverySource::Salvaged { fields } => {
7628 assert!(
7629 fields.iter().any(|f| f == "http_proxy"),
7630 "salvaged fields should list the recovered key: {fields:?}"
7631 );
7632 }
7633 other => panic!("expected Salvaged source, got {other:?}"),
7634 }
7635 }
7636
7637 #[test]
7638 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7639 let temp = TempHome::new();
7640 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7642
7643 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7644 let status = config.recovery_status().expect("pending recovery");
7645 assert!(!status.confirmed);
7646 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7647 }
7648
7649 #[test]
7650 fn clean_load_never_sets_recovery_status() {
7651 let temp = TempHome::new();
7652 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7653
7654 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7655 assert!(
7656 config.recovery_status().is_none(),
7657 "a config.json that parses cleanly must never carry a pending recovery status"
7658 );
7659 }
7660
7661 #[test]
7662 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7663 let temp = TempHome::new();
7664 let corrupt_bytes = "}}} broken";
7665 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7666
7667 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7668 assert!(config.recovery_status().is_some());
7669
7670 let err = config
7671 .save_to_dir(temp.path.clone())
7672 .expect_err("save must refuse while recovery is unconfirmed");
7673 assert!(
7674 err.to_string().contains("recovered from corruption")
7675 || err.to_string().contains("confirm"),
7676 "error should explain the refused overwrite: {err}"
7677 );
7678
7679 assert_eq!(
7682 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7683 corrupt_bytes,
7684 "a refused save must leave the corrupt original untouched"
7685 );
7686 }
7687
7688 #[test]
7689 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7690 let temp = TempHome::new();
7691 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7693 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7694
7695 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7696 let status = config.recovery_status().expect("pending recovery");
7697 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7698 assert_eq!(
7699 std::fs::read_to_string(quarantine_path).unwrap(),
7700 truncated,
7701 "truncated original preserved byte for byte in quarantine"
7702 );
7703
7704 let err = config.save_to_dir(temp.path.clone());
7705 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7706 assert_eq!(
7707 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7708 truncated,
7709 "the half-written original stays exactly as it was after a refused save"
7710 );
7711 }
7712
7713 #[test]
7714 fn confirm_recovery_allows_the_next_save() {
7715 let temp = TempHome::new();
7716 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7717
7718 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7719 assert!(config.recovery_status().is_some());
7720
7721 config.confirm_recovery();
7722 assert!(
7723 config.recovery_status().is_some_and(|s| s.confirmed),
7724 "confirm_recovery flips the flag but keeps the status around"
7725 );
7726
7727 config
7728 .save_to_dir(temp.path.clone())
7729 .expect("save must succeed once the recovery is confirmed");
7730 }
7731
7732 #[test]
7733 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7734 let temp = TempHome::new();
7735 std::fs::write(
7736 temp.path.join("config.json"),
7737 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7738 )
7739 .unwrap();
7740
7741 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7742 assert!(config.recovery_status().is_some());
7743 let quarantine_path = config
7744 .recovery_status()
7745 .unwrap()
7746 .quarantine_path
7747 .clone()
7748 .unwrap();
7749
7750 config
7751 .confirm_recovery_and_save_to_dir(temp.path.clone())
7752 .expect("confirm+save should succeed");
7753
7754 assert!(
7755 config.recovery_status().is_none(),
7756 "the pending flag is cleared once the recovery is confirmed and persisted"
7757 );
7758 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7759 assert!(
7760 on_disk.contains("http://recovered"),
7761 "config.json now holds the recovered (salvaged) state"
7762 );
7763 assert!(
7766 quarantine_path.exists(),
7767 "the quarantined original survives confirmation — it's never deleted"
7768 );
7769 }
7770
7771 #[test]
7772 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7773 let temp = TempHome::new();
7774 let mut config = Config::create_default();
7775 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7776 assert!(
7777 err.is_err(),
7778 "confirming a recovery that was never pending must error, not silently succeed"
7779 );
7780 }
7781
7782 fn connect_platform_with_encrypted(
7785 platform_type: &str,
7786 token_encrypted: &str,
7787 ) -> ConnectPlatformConfig {
7788 ConnectPlatformConfig {
7789 id: None,
7790 project_id: None,
7791 platform_type: platform_type.to_string(),
7792 token: None,
7793 token_encrypted: Some(token_encrypted.to_string()),
7794 token_credential_ref: None,
7795 token_configured: false,
7796 app_id: None,
7797 app_secret: None,
7798 app_secret_encrypted: None,
7799 app_secret_credential_ref: None,
7800 app_secret_configured: false,
7801 domain: None,
7802 allow_from: vec!["user-1".to_string()],
7803 admin_from: Vec::new(),
7804 }
7805 }
7806
7807 fn connect_json_path(temp: &TempHome) -> PathBuf {
7808 temp.path.join("connect.json")
7809 }
7810
7811 #[test]
7812 fn save_splits_connect_into_sibling_connect_json() {
7813 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7814 let temp = TempHome::new();
7815
7816 let mut config = Config::create_default();
7817 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7818 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7819
7820 config
7821 .save_to_dir(temp.path.clone())
7822 .expect("save succeeds");
7823
7824 let config_json: serde_json::Value =
7825 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7826 .unwrap();
7827 assert!(
7828 config_json.get("connect").is_none(),
7829 "config.json must not carry the `connect` key after a save"
7830 );
7831
7832 let connect_json: serde_json::Value =
7833 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7834 .unwrap();
7835 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7836 assert!(
7837 connect_json["platforms"][0]["token_encrypted"]
7838 .as_str()
7839 .is_some_and(|v| !v.is_empty()),
7840 "the token is persisted in its encrypted form in connect.json"
7841 );
7842 assert!(
7843 connect_json["platforms"][0].get("token").is_none(),
7844 "the plaintext token is never persisted (skip_serializing)"
7845 );
7846 }
7847
7848 #[test]
7851 fn save_assigns_a_missing_connect_platform_id() {
7852 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7853 let temp = TempHome::new();
7854
7855 let mut config = Config::create_default();
7856 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7857 assert!(
7858 config.connect.platforms[0].id.is_none(),
7859 "precondition: the entry starts without an id"
7860 );
7861
7862 config
7863 .save_to_dir(temp.path.clone())
7864 .expect("save succeeds");
7865
7866 let connect_json: serde_json::Value =
7867 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7868 .unwrap();
7869 let persisted_id = connect_json["platforms"][0]["id"]
7870 .as_str()
7871 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7872 assert!(!persisted_id.is_empty());
7873
7874 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7875 assert_eq!(
7876 reloaded.connect.platforms[0].id.as_deref(),
7877 Some(persisted_id),
7878 "the assigned id round-trips through a reload"
7879 );
7880 }
7881
7882 #[test]
7883 fn save_never_reassigns_an_existing_connect_platform_id() {
7884 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7885 let temp = TempHome::new();
7886
7887 let mut config = Config::create_default();
7888 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7889 platform.id = Some("stable-id-123".to_string());
7890 config.connect.platforms = vec![platform];
7891
7892 config
7893 .save_to_dir(temp.path.clone())
7894 .expect("first save succeeds");
7895 config
7897 .save_to_dir(temp.path.clone())
7898 .expect("second save succeeds");
7899
7900 let connect_json: serde_json::Value =
7901 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7902 .unwrap();
7903 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7904 }
7905
7906 #[test]
7907 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7908 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7909 let temp = TempHome::new();
7910
7911 let mut config = Config::create_default();
7912 config.connect.platforms = vec![
7913 connect_platform_with_encrypted("telegram", "cipher-a"),
7914 connect_platform_with_encrypted("telegram", "cipher-b"),
7915 ];
7916
7917 config
7918 .save_to_dir(temp.path.clone())
7919 .expect("save succeeds");
7920
7921 let connect_json: serde_json::Value =
7922 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7923 .unwrap();
7924 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7925 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7926 assert_ne!(
7927 id_a, id_b,
7928 "two entries sharing platform_type must still get distinct ids"
7929 );
7930 }
7931
7932 #[test]
7933 fn load_never_assigns_or_persists_an_id_by_itself() {
7934 let temp = TempHome::new();
7935 std::fs::write(
7936 connect_json_path(&temp),
7937 serde_json::json!({
7938 "platforms": [
7939 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7940 ]
7941 })
7942 .to_string(),
7943 )
7944 .unwrap();
7945 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7946
7947 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7948
7949 assert!(
7950 config.connect.platforms[0].id.is_none(),
7951 "load alone must not backfill an id in memory"
7952 );
7953 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7954 assert_eq!(
7955 connect_json_before, connect_json_after,
7956 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7957 );
7958 }
7959
7960 #[test]
7961 fn load_merges_connect_json_into_config() {
7962 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7963 let temp = TempHome::new();
7964 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7965 std::fs::write(
7966 connect_json_path(&temp),
7967 serde_json::json!({
7968 "platforms": [
7969 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7970 ]
7971 })
7972 .to_string(),
7973 )
7974 .unwrap();
7975
7976 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7977 assert_eq!(config.connect.platforms.len(), 1);
7978 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7979 assert_eq!(
7980 config.connect.platforms[0].token.as_deref(),
7981 Some("connect-secret")
7982 );
7983 assert!(config.connect.platforms[0].token_encrypted.is_none());
7984 }
7985
7986 #[test]
7987 fn load_without_connect_json_yields_empty_inert_connect_config() {
7988 let temp = TempHome::new();
7989 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7990
7991 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7992 assert!(
7993 config.connect.platforms.is_empty(),
7994 "no connect.json and no legacy key -> empty/inert connect config"
7995 );
7996 assert!(
7997 !connect_json_path(&temp).exists(),
7998 "load must not create connect.json when there is nothing to migrate"
7999 );
8000 }
8001
8002 #[test]
8003 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
8004 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
8005 let temp = TempHome::new();
8006 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
8007 let legacy_cipher_for_assert = legacy_cipher.clone();
8008 temp.set_config_json(
8010 &serde_json::json!({
8011 "http_proxy": "http://keep-me",
8012 "connect": {
8013 "platforms": [
8014 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
8015 ]
8016 }
8017 })
8018 .to_string(),
8019 );
8020
8021 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8022
8023 assert_eq!(config.connect.platforms.len(), 1);
8025 assert_eq!(
8026 config.connect.platforms[0].token.as_deref(),
8027 Some("legacy-connect-secret")
8028 );
8029 assert_eq!(config.http_proxy, "http://keep-me");
8031
8032 assert!(
8034 connect_json_path(&temp).exists(),
8035 "migration proactively creates connect.json"
8036 );
8037 let connect_json: serde_json::Value =
8038 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8039 .unwrap();
8040 assert_eq!(
8041 connect_json["platforms"][0]["token_encrypted"],
8042 legacy_cipher_for_assert
8043 );
8044
8045 let config_json: serde_json::Value =
8047 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8048 .unwrap();
8049 assert!(
8050 config_json.get("connect").is_none(),
8051 "config.json is rewritten without the legacy `connect` key"
8052 );
8053 }
8054
8055 #[test]
8063 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8064 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8065 let temp = TempHome::new();
8066
8067 let original_api_key_encrypted =
8068 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8069 temp.set_config_json(
8070 &serde_json::json!({
8071 "providers": {
8072 "openai": {
8073 "api_key_encrypted": original_api_key_encrypted,
8074 }
8075 },
8076 "connect": {
8077 "platforms": [
8078 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8079 ]
8080 }
8081 })
8082 .to_string(),
8083 );
8084
8085 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8086 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8087
8088 assert!(
8091 !temp.path.join("config.json.bak").exists(),
8092 "a read-only load migrating a legacy `connect` key must not rotate \
8093 config.json backups"
8094 );
8095
8096 let config_json: serde_json::Value =
8101 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8102 .unwrap();
8103 assert_eq!(
8104 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8105 "an unrelated secret's ciphertext must not be touched by the connect \
8106 migration's narrow write"
8107 );
8108 assert!(
8109 config_json.get("connect").is_none(),
8110 "config.json is still rewritten without the legacy `connect` key"
8111 );
8112 }
8113
8114 #[test]
8115 fn both_files_present_connect_json_wins() {
8116 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8117 let temp = TempHome::new();
8118 let stale = crate::encryption::encrypt("stale-secret").unwrap();
8119 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8120 temp.set_config_json(
8121 &serde_json::json!({
8122 "connect": {
8123 "platforms": [
8124 { "type": "telegram", "token_encrypted": stale }
8125 ]
8126 }
8127 })
8128 .to_string(),
8129 );
8130 std::fs::write(
8131 connect_json_path(&temp),
8132 serde_json::json!({
8133 "platforms": [
8134 { "type": "telegram", "token_encrypted": authoritative }
8135 ]
8136 })
8137 .to_string(),
8138 )
8139 .unwrap();
8140
8141 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8142 assert_eq!(
8143 config.connect.platforms[0].token.as_deref(),
8144 Some("authoritative-secret"),
8145 "connect.json wins over a stale legacy config.json key"
8146 );
8147 }
8148
8149 #[test]
8154 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8155 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8156 let temp = TempHome::new();
8157 let stale = crate::encryption::encrypt("stale-secret").unwrap();
8158 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8159 temp.set_config_json(
8160 &serde_json::json!({
8161 "http_proxy": "http://keep-me",
8162 "connect": {
8163 "platforms": [
8164 { "type": "telegram", "token_encrypted": stale }
8165 ]
8166 }
8167 })
8168 .to_string(),
8169 );
8170 std::fs::write(
8171 connect_json_path(&temp),
8172 serde_json::json!({
8173 "platforms": [
8174 { "type": "telegram", "token_encrypted": authoritative }
8175 ]
8176 })
8177 .to_string(),
8178 )
8179 .unwrap();
8180
8181 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8182 assert_eq!(
8183 config.connect.platforms[0].token.as_deref(),
8184 Some("authoritative-secret")
8185 );
8186 assert_eq!(config.http_proxy, "http://keep-me");
8188
8189 let config_json: serde_json::Value =
8190 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8191 .unwrap();
8192 assert!(
8193 config_json.get("connect").is_none(),
8194 "the stale legacy `connect` key must be stripped from config.json \
8195 immediately on load, not left for the next natural save"
8196 );
8197 }
8198
8199 #[test]
8207 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8208 let temp = TempHome::new();
8209 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8210 std::fs::write(
8211 temp.path.join("config.json.bak"),
8212 serde_json::json!({
8213 "http_proxy": "http://old",
8214 "connect": {
8215 "platforms": [
8216 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8217 ]
8218 }
8219 })
8220 .to_string(),
8221 )
8222 .unwrap();
8223
8224 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8225 assert!(config.connect.platforms.is_empty());
8228
8229 let bak: serde_json::Value = serde_json::from_str(
8230 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8231 )
8232 .unwrap();
8233 assert!(
8234 bak.get("connect").is_none(),
8235 "the legacy `connect` key must be stripped from the tainted .bak generation"
8236 );
8237 assert_eq!(
8238 bak["http_proxy"], "http://old",
8239 "every other key in the .bak generation survives the scrub byte-for-byte in content"
8240 );
8241 }
8242
8243 #[test]
8248 fn scrub_reaches_all_rotated_generations() {
8249 let temp = TempHome::new();
8250 temp.set_config_json(&serde_json::json!({}).to_string());
8251 for (gen_suffix, cipher) in [
8252 ("config.json.bak", "cipher-gen0"),
8253 ("config.json.bak.1", "cipher-gen1"),
8254 ("config.json.bak.2", "cipher-gen2"),
8255 ] {
8256 std::fs::write(
8257 temp.path.join(gen_suffix),
8258 serde_json::json!({
8259 "connect": {
8260 "platforms": [
8261 { "type": "telegram", "token_encrypted": cipher }
8262 ]
8263 }
8264 })
8265 .to_string(),
8266 )
8267 .unwrap();
8268 }
8269
8270 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8271
8272 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8273 let value: serde_json::Value =
8274 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8275 .unwrap();
8276 assert!(
8277 value.get("connect").is_none(),
8278 "{gen_suffix} must have its legacy `connect` key stripped"
8279 );
8280 }
8281 }
8282
8283 #[test]
8291 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8292 let temp = TempHome::new();
8293 temp.set_config_json(&serde_json::json!({}).to_string());
8294 let bak_path = temp.path.join("config.json.bak");
8295 std::fs::write(
8296 &bak_path,
8297 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8298 )
8299 .unwrap();
8300
8301 let before_bytes = std::fs::read(&bak_path).unwrap();
8302 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8303
8304 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8308
8309 let after_bytes = std::fs::read(&bak_path).unwrap();
8310 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8311 assert_eq!(
8312 before_bytes, after_bytes,
8313 "a .bak generation without a legacy `connect` key must not be rewritten at all"
8314 );
8315 assert_eq!(
8316 before_mtime, after_mtime,
8317 "no write means no mtime change either"
8318 );
8319 }
8320
8321 #[test]
8326 fn scrub_skips_unparsable_backup_without_deleting_it() {
8327 let temp = TempHome::new();
8328 temp.set_config_json(&serde_json::json!({}).to_string());
8329 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8330
8331 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8332
8333 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8334 assert_eq!(
8335 content, "{ not valid json",
8336 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8337 );
8338 }
8339
8340 #[test]
8344 fn scrub_tolerates_missing_generations() {
8345 let temp = TempHome::new();
8346 temp.set_config_json(&serde_json::json!({}).to_string());
8347 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8349 assert!(config.connect.platforms.is_empty());
8350 assert!(!temp.path.join("config.json.bak").exists());
8351 }
8352
8353 #[test]
8356 fn scrub_does_not_break_backup_rotation() {
8357 let temp = TempHome::new();
8358 std::fs::write(
8359 temp.path.join("config.json"),
8360 serde_json::json!({
8361 "http_proxy": "http://proxy-v1",
8362 "connect": {
8363 "platforms": [
8364 { "type": "telegram", "token_encrypted": "legacy-cipher" }
8365 ]
8366 }
8367 })
8368 .to_string(),
8369 )
8370 .unwrap();
8371 std::fs::write(
8372 temp.path.join("config.json.bak"),
8373 serde_json::json!({
8374 "http_proxy": "http://proxy-v0",
8375 "connect": {
8376 "platforms": [
8377 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8378 ]
8379 }
8380 })
8381 .to_string(),
8382 )
8383 .unwrap();
8384
8385 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8387 let bak: serde_json::Value = serde_json::from_str(
8388 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8389 )
8390 .unwrap();
8391 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8392
8393 config.http_proxy = "http://proxy-v2".to_string();
8396 config.save_to_dir(temp.path.clone()).unwrap();
8397
8398 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8399 assert!(
8400 new_bak.contains("proxy-v1"),
8401 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8402 );
8403 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8404 assert!(
8405 new_bak1.contains("proxy-v0"),
8406 ".bak.1 holds the scrubbed older generation after rotation"
8407 );
8408 assert!(
8409 !new_bak1.contains("legacy-bak-cipher"),
8410 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8411 stripped secret"
8412 );
8413 }
8414
8415 #[test]
8416 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8417 let temp = TempHome::new();
8418 temp.set_config_json("{}");
8419 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8420
8421 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8422 assert!(
8423 config.connect.platforms.is_empty(),
8424 "corrupt connect.json fails SAFE to an empty/inert connect config"
8425 );
8426
8427 let backup = connect_json_path(&temp).with_extension("json.bak");
8428 assert!(
8429 backup.exists(),
8430 "the corrupt connect.json is quarantined to connect.json.bak"
8431 );
8432 assert!(
8433 std::fs::read_to_string(backup)
8434 .unwrap()
8435 .contains("not valid json"),
8436 "the quarantined copy holds the bad content"
8437 );
8438 assert!(
8443 !connect_json_path(&temp).exists(),
8444 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8445 connect.json should remain after quarantine"
8446 );
8447 }
8448
8449 #[test]
8450 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8451 let temp = TempHome::new();
8452 temp.set_config_json(
8455 &serde_json::json!({
8456 "connect": {
8457 "platforms": [
8458 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8459 ]
8460 }
8461 })
8462 .to_string(),
8463 );
8464 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8465
8466 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8467 assert!(
8468 config.connect.platforms.is_empty(),
8469 "corrupt connect.json must not fall back to the legacy config.json copy"
8470 );
8471 }
8472
8473 #[test]
8474 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8475 let temp = TempHome::new();
8476 let config = Config::create_default();
8477 assert!(config.connect.platforms.is_empty());
8478
8479 config
8480 .save_to_dir(temp.path.clone())
8481 .expect("save succeeds");
8482
8483 assert!(
8484 !connect_json_path(&temp).exists(),
8485 "an empty connect config with no pre-existing file must not create one"
8486 );
8487 }
8488
8489 #[test]
8490 fn connect_json_backed_up_before_overwrite() {
8491 let temp = TempHome::new();
8492 std::fs::write(
8493 connect_json_path(&temp),
8494 serde_json::json!({
8495 "platforms": [
8496 { "type": "telegram", "token_encrypted": "old-cipher" }
8497 ]
8498 })
8499 .to_string(),
8500 )
8501 .unwrap();
8502
8503 let mut config = Config::create_default();
8504 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8505 config
8506 .save_to_dir(temp.path.clone())
8507 .expect("save succeeds");
8508
8509 let backup = connect_json_path(&temp).with_extension("json.bak");
8510 assert!(
8511 std::fs::read_to_string(backup)
8512 .unwrap()
8513 .contains("old-cipher"),
8514 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8515 );
8516 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8517 assert!(current.contains("new-cipher"));
8518 }
8519
8520 #[test]
8523 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8524 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8525 let temp = TempHome::new();
8526
8527 let mut config = Config::create_default();
8528 config.connect.platforms = vec![ConnectPlatformConfig {
8529 id: None,
8530 project_id: None,
8531 platform_type: "feishu".to_string(),
8532 token: None,
8533 token_encrypted: None,
8534 token_credential_ref: None,
8535 token_configured: false,
8536 app_id: Some("cli_real_app_id".to_string()),
8537 app_secret: Some("plain-app-secret".to_string()),
8538 app_secret_encrypted: None,
8539 app_secret_credential_ref: None,
8540 app_secret_configured: false,
8541 domain: Some("lark".to_string()),
8542 allow_from: vec!["ou_1".to_string()],
8543 admin_from: Vec::new(),
8544 }];
8545
8546 config
8547 .save_to_dir(temp.path.clone())
8548 .expect("save succeeds");
8549
8550 let connect_json: serde_json::Value =
8551 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8552 .unwrap();
8553 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8554 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8555 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8556 assert!(
8557 connect_json["platforms"][0]["app_secret_encrypted"]
8558 .as_str()
8559 .is_some_and(|v| !v.is_empty()),
8560 "app_secret is persisted in its encrypted form in connect.json"
8561 );
8562 assert!(
8563 connect_json["platforms"][0].get("app_secret").is_none(),
8564 "the plaintext app_secret is never persisted (skip_serializing)"
8565 );
8566 }
8567
8568 #[test]
8569 fn load_hydrates_feishu_app_secret_from_encrypted() {
8570 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8571 let temp = TempHome::new();
8572
8573 let mut config = Config::create_default();
8574 config.connect.platforms = vec![ConnectPlatformConfig {
8575 id: None,
8576 project_id: None,
8577 platform_type: "feishu".to_string(),
8578 token: None,
8579 token_encrypted: None,
8580 token_credential_ref: None,
8581 token_configured: false,
8582 app_id: Some("cli_real_app_id".to_string()),
8583 app_secret: Some("plain-app-secret".to_string()),
8584 app_secret_encrypted: None,
8585 app_secret_credential_ref: None,
8586 app_secret_configured: false,
8587 domain: Some("lark".to_string()),
8588 allow_from: vec!["ou_1".to_string()],
8589 admin_from: Vec::new(),
8590 }];
8591 config
8592 .save_to_dir(temp.path.clone())
8593 .expect("save succeeds");
8594
8595 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8596 assert_eq!(reloaded.connect.platforms.len(), 1);
8597 assert_eq!(
8598 reloaded.connect.platforms[0].app_secret.as_deref(),
8599 Some("plain-app-secret"),
8600 "reload hydrates app_secret from app_secret_encrypted"
8601 );
8602 assert_eq!(
8603 reloaded.connect.platforms[0].app_id.as_deref(),
8604 Some("cli_real_app_id")
8605 );
8606 assert_eq!(
8607 reloaded.connect.platforms[0].domain.as_deref(),
8608 Some("lark")
8609 );
8610 }
8611
8612 #[test]
8613 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8614 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8615 let temp = TempHome::new();
8616 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8617 std::fs::write(
8618 connect_json_path(&temp),
8619 serde_json::json!({
8620 "platforms": [
8621 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8622 ]
8623 })
8624 .to_string(),
8625 )
8626 .unwrap();
8627
8628 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8629
8630 assert_eq!(config.connect.platforms.len(), 1);
8631 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8632 assert_eq!(
8633 config.connect.platforms[0].token.as_deref(),
8634 Some("legacy-telegram-secret")
8635 );
8636 assert_eq!(
8637 config.connect.platforms[0].app_id, None,
8638 "a legacy entry with no Feishu fields deserializes them as None"
8639 );
8640 assert_eq!(config.connect.platforms[0].app_secret, None);
8641 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8642 assert_eq!(config.connect.platforms[0].domain, None);
8643 }
8644
8645 #[test]
8646 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8647 let _lock = env_lock_acquire();
8648 let temp_home = TempHome::new();
8649 temp_home.set_config_json(
8650 r#"{
8651 "provider": "openai",
8652 "providers": {
8653 "openai": {
8654 "api_key": "sk-test",
8655 "model": "gpt-4o"
8656 }
8657 }
8658}"#,
8659 );
8660
8661 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8662 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8663
8664 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8665
8666 assert_eq!(
8667 config
8668 .providers
8669 .openai
8670 .as_ref()
8671 .and_then(|c| c.model.as_deref()),
8672 Some("gpt-4o")
8673 );
8674 assert!(
8675 config.http_proxy.is_empty(),
8676 "config should keep http_proxy empty when field is omitted"
8677 );
8678 assert!(
8679 config.https_proxy.is_empty(),
8680 "config should keep https_proxy empty when field is omitted"
8681 );
8682 }
8683
8684 #[test]
8685 fn get_memory_background_model_prefers_memory_specific_override() {
8686 let mut config = Config::default();
8687 config.features.provider_model_ref = false;
8688 config.provider = "openai".to_string();
8689 config.providers.openai = Some(OpenAIConfig {
8690 api_key: "test".to_string(),
8691 api_key_encrypted: None,
8692 credential_ref: None,
8693 base_url: None,
8694 model: Some("gpt-main".to_string()),
8695 fast_model: Some("gpt-fast".to_string()),
8696 vision_model: None,
8697 reasoning_effort: None,
8698 responses_only_models: vec![],
8699 request_overrides: None,
8700 extra: BTreeMap::new(),
8701 api_key_from_env: false,
8702 });
8703 config.memory.0 = Some(MemoryConfig {
8704 background_model: Some("memory-fast".to_string()),
8705 ..MemoryConfig::default()
8706 });
8707
8708 assert_eq!(
8709 config.get_memory_background_model().as_deref(),
8710 Some("memory-fast")
8711 );
8712 }
8713
8714 #[test]
8715 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8716 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8721 api_key: api_key.to_string(),
8722 api_key_encrypted: None,
8723 credential_ref: None,
8724 base_url: None,
8725 model: None,
8726 fast_model: None,
8727 vision_model: None,
8728 reasoning_effort: None,
8729 responses_only_models: vec![],
8730 request_overrides: None,
8731 extra: BTreeMap::new(),
8732 api_key_from_env: from_env,
8733 };
8734
8735 let mut current = Config::default();
8737 current.providers.openai = Some(openai("sk-env", true));
8738 let mut merged = Config::default();
8739 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8741 let got = merged.providers.openai.as_ref().unwrap();
8742 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8743 assert!(got.api_key_from_env, "env flag restored");
8744
8745 let mut merged = Config::default();
8747 merged.providers.openai = Some(openai("sk-explicit", false));
8748 merged.preserve_env_sourced_provider_keys(¤t);
8749 assert_eq!(
8750 merged.providers.openai.as_ref().unwrap().api_key,
8751 "sk-explicit",
8752 "explicit patch key must win"
8753 );
8754
8755 let mut current_plain = Config::default();
8757 current_plain.providers.openai = Some(openai("sk-plain", false));
8758 let mut merged = Config::default();
8759 merged.providers.openai = Some(openai("", false));
8760 merged.preserve_env_sourced_provider_keys(¤t_plain);
8761 assert!(
8762 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8763 "non-env key must not be restored by this path"
8764 );
8765 }
8766
8767 #[test]
8768 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8769 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8773 api_key: api_key.to_string(),
8774 api_key_encrypted: enc.map(str::to_string),
8775 credential_ref: None,
8776 base_url: None,
8777 model: None,
8778 fast_model: None,
8779 vision_model: None,
8780 reasoning_effort: None,
8781 responses_only_models: vec![],
8782 request_overrides: None,
8783 extra: BTreeMap::new(),
8784 api_key_from_env: false,
8785 };
8786
8787 let mut config = Config::default();
8789 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8790 config
8791 .refresh_provider_api_keys_encrypted()
8792 .expect("refresh");
8793 assert_eq!(
8794 config
8795 .providers
8796 .openai
8797 .as_ref()
8798 .unwrap()
8799 .api_key_encrypted
8800 .as_deref(),
8801 Some("preexisting-ciphertext"),
8802 "existing ciphertext must be preserved when plaintext is empty"
8803 );
8804
8805 let mut config = Config::default();
8807 config.providers.openai = Some(openai("", None));
8808 config
8809 .refresh_provider_api_keys_encrypted()
8810 .expect("refresh");
8811 assert!(
8812 config
8813 .providers
8814 .openai
8815 .as_ref()
8816 .unwrap()
8817 .api_key_encrypted
8818 .is_none(),
8819 "no key + no ciphertext should stay None"
8820 );
8821
8822 let mut config = Config::default();
8824 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8825 config
8826 .refresh_provider_api_keys_encrypted()
8827 .expect("refresh");
8828 let enc = config
8829 .providers
8830 .openai
8831 .as_ref()
8832 .unwrap()
8833 .api_key_encrypted
8834 .clone()
8835 .expect("ciphertext present");
8836 assert!(
8837 !enc.is_empty() && enc != "stale-ciphertext",
8838 "plaintext re-encrypted"
8839 );
8840 }
8841
8842 #[test]
8843 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8844 let mut config = Config::default();
8850 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8851 "provider_type": "openai",
8852 "api_key": "sk-instance-live",
8853 }))
8854 .expect("valid instance");
8855 config
8856 .provider_instances
8857 .insert("work".to_string(), instance);
8858
8859 config.refresh_encrypted_secrets().expect("refresh");
8860 assert!(
8861 config.provider_instances["work"]
8862 .api_key_encrypted
8863 .is_some(),
8864 "live config must hold ciphertext after refresh"
8865 );
8866
8867 let value = serde_json::to_value(&config).expect("serialize");
8869 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8870 assert!(
8871 back.provider_instances["work"].api_key.is_empty(),
8872 "plaintext is skip_serializing"
8873 );
8874 back.hydrate_provider_instance_api_keys_from_encrypted();
8875 assert_eq!(
8876 back.provider_instances["work"].api_key, "sk-instance-live",
8877 "key must be recoverable from the round-tripped ciphertext"
8878 );
8879 }
8880
8881 #[test]
8882 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8883 let mut config = Config::default();
8884 config.features.provider_model_ref = false;
8885 config.provider = "openai".to_string();
8886 config.providers.openai = Some(OpenAIConfig {
8887 api_key: "test".to_string(),
8888 api_key_encrypted: None,
8889 credential_ref: None,
8890 base_url: None,
8891 model: Some("gpt-main".to_string()),
8892 fast_model: Some("gpt-fast".to_string()),
8893 vision_model: None,
8894 reasoning_effort: None,
8895 responses_only_models: vec![],
8896 request_overrides: None,
8897 extra: BTreeMap::new(),
8898 api_key_from_env: false,
8899 });
8900
8901 assert_eq!(
8902 config.get_memory_background_model().as_deref(),
8903 Some("gpt-fast")
8904 );
8905 }
8906
8907 #[test]
8908 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8909 let mut config = Config::default();
8910 config.features.provider_model_ref = false;
8911 config.provider = "openai".to_string();
8912 config.providers.openai = Some(OpenAIConfig {
8913 api_key: "sk-stale".to_string(),
8914 model: Some("legacy-main".to_string()),
8915 fast_model: Some("legacy-fast".to_string()),
8916 vision_model: Some("legacy-vision".to_string()),
8917 reasoning_effort: Some(ReasoningEffort::Low),
8918 ..OpenAIConfig::default()
8919 });
8920 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8921 "provider_type": "openai",
8922 "model": "instance-main",
8923 "fast_model": "instance-fast",
8924 "vision_model": "instance-vision",
8925 "reasoning_effort": "high",
8926 "enabled": true
8927 }))
8928 .unwrap();
8929 instance.api_key = "sk-instance".to_string();
8930 config
8931 .provider_instances
8932 .insert("work".to_string(), instance);
8933 config.default_provider_instance = Some("work".to_string());
8934
8935 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8936 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8937 assert_eq!(
8938 config.get_memory_background_model().as_deref(),
8939 Some("instance-fast")
8940 );
8941 assert_eq!(
8942 config.get_task_summary_model().as_deref(),
8943 Some("instance-fast")
8944 );
8945 assert_eq!(
8946 config.get_vision_model().as_deref(),
8947 Some("instance-vision")
8948 );
8949 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8950 }
8951
8952 #[test]
8953 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8954 let mut config = Config::default();
8955 config.features.provider_model_ref = true;
8956 config.provider_instances.insert(
8957 "compatibility-default".to_string(),
8958 serde_json::from_value(serde_json::json!({
8959 "provider_type": "openai",
8960 "reasoning_effort": "low",
8961 "enabled": true
8962 }))
8963 .unwrap(),
8964 );
8965 config.provider_instances.insert(
8966 "chat-provider".to_string(),
8967 serde_json::from_value(serde_json::json!({
8968 "provider_type": "anthropic",
8969 "reasoning_effort": "high",
8970 "enabled": true
8971 }))
8972 .unwrap(),
8973 );
8974 config.default_provider_instance = Some("compatibility-default".to_string());
8975 config.defaults = Some(
8976 serde_json::from_value(serde_json::json!({
8977 "chat": { "provider": "chat-provider", "model": "claude" }
8978 }))
8979 .unwrap(),
8980 );
8981
8982 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8983
8984 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8985 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8986 }
8987
8988 #[test]
8989 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8990 let _provider =
8991 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8992 let _openai_key = crate::test_support::override_runtime_env_var(
8993 "BAMBOO_OPENAI_API_KEY",
8994 Some("sk-runtime-env-instance"),
8995 );
8996 let _anthropic_key =
8997 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8998 let _gemini_key =
8999 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9000
9001 let mut config = Config::default();
9002 *config.providers_mut() = ProviderConfigs::default();
9003 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
9004 let mut extra = serde_json::Map::new();
9005 if from_environment {
9006 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
9007 }
9008 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
9009 extra.insert("enabled".to_string(), serde_json::json!(true));
9010 config.provider_instances.insert(
9011 id.to_string(),
9012 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
9013 );
9014 }
9015
9016 config.apply_runtime_env_overrides();
9017
9018 assert_eq!(
9019 config.default_provider_instance.as_deref(),
9020 Some("a-marked"),
9021 "type override selects the lexicographically first enabled instance"
9022 );
9023 assert_eq!(
9024 config.provider_instances["a-marked"].api_key,
9025 "sk-runtime-env-instance"
9026 );
9027 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
9028 assert!(
9029 config.providers().openai.is_none(),
9030 "instance-mode env hydration must not recreate a legacy alias"
9031 );
9032
9033 config.refresh_encrypted_secrets().unwrap();
9034 let serialized = serde_json::to_string(&config).unwrap();
9035 assert!(!serialized.contains("sk-runtime-env-instance"));
9036 assert!(
9037 config.provider_instances["a-marked"]
9038 .api_key_encrypted
9039 .is_none(),
9040 "runtime env plaintext must not be encrypted into ordinary config"
9041 );
9042 }
9043
9044 #[test]
9045 fn runtime_provider_override_prefers_exact_instance_id() {
9046 let _provider =
9047 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9048 let _openai_key =
9049 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9050 let _anthropic_key =
9051 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9052 let _gemini_key =
9053 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9054 let mut config = Config::default();
9055 for id in ["work", "personal"] {
9056 config.provider_instances.insert(
9057 id.to_string(),
9058 serde_json::from_value(serde_json::json!({
9059 "provider_type": "openai",
9060 "enabled": true
9061 }))
9062 .unwrap(),
9063 );
9064 }
9065
9066 config.apply_runtime_env_overrides();
9067 assert_eq!(
9068 config.default_provider_instance.as_deref(),
9069 Some("personal")
9070 );
9071 }
9072
9073 #[test]
9074 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9075 let mut config = Config::default();
9076 config.features.provider_model_ref = false;
9077 config.provider = "openai".to_string();
9078 config.providers.openai = Some(OpenAIConfig {
9079 api_key: "test".to_string(),
9080 api_key_encrypted: None,
9081 credential_ref: None,
9082 base_url: None,
9083 model: Some("gpt-main".to_string()),
9084 fast_model: None,
9085 vision_model: None,
9086 reasoning_effort: None,
9087 responses_only_models: vec![],
9088 request_overrides: None,
9089 extra: BTreeMap::new(),
9090 api_key_from_env: false,
9091 });
9092
9093 assert_eq!(
9094 config.get_memory_background_model().as_deref(),
9095 Some("gpt-main")
9096 );
9097 }
9098
9099 #[test]
9100 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9101 let legacy = serde_json::json!({
9102 "memory": MemoryConfig {
9103 background_model: Some("dream-fast".to_string()),
9104 summary_target_ratio: 0.20,
9105 summary_safe_window_percent: 80,
9106 auto_dream_enabled: true,
9107 auto_dream_interval_secs: 900,
9108 project_prompt_injection: false,
9109 relevant_recall: false,
9110 relevant_recall_rerank: true,
9111 project_first_dream: false,
9112 ledger_agenda_injection: false,
9113 ledger_gardener_enabled: false,
9114 ledger_gardener_interval_secs: 7_200,
9115 ledger_distillation_enabled: false,
9116 dream_refine_mode: true,
9117 gardener_enabled: true,
9118 gardener_interval_secs: 3_600,
9119 gardener_volume_trigger: 40,
9120 gardener_max_splits_per_run: 4,
9121 gardener_min_sections: 7,
9122 dedup_gardener_enabled: true,
9123 dedup_gardener_min_score: 0.7,
9124 dedup_gardener_max_merges_per_run: 3,
9125 memory_active_capacity: 500,
9126 capacity_max_archivals_per_run: 10,
9127 granularity_freshness_gardener_enabled: false,
9128 }
9129 });
9130 let config: Config = serde_json::from_value(legacy).unwrap();
9131
9132 let serialized = serde_json::to_value(&config).expect("config should serialize");
9133 assert!(serialized.get("memory").is_some());
9134 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9135 assert!(round_tripped
9136 .memory()
9137 .as_ref()
9138 .is_some_and(|memory| memory.dream_refine_mode));
9139 let memory = config.memory.as_ref().expect("memory config should exist");
9140 assert!(memory.auto_dream_enabled);
9141 assert!(!memory.project_prompt_injection);
9142 assert!(!memory.relevant_recall);
9143 assert!(memory.relevant_recall_rerank);
9144 assert!(!memory.project_first_dream);
9145 assert!(memory.dream_refine_mode);
9146 assert!(memory.gardener_enabled);
9147 assert_eq!(memory.gardener_interval_secs, 3_600);
9148 assert_eq!(memory.gardener_volume_trigger, 40);
9149 assert_eq!(memory.gardener_max_splits_per_run, 4);
9150 assert_eq!(memory.gardener_min_sections, 7);
9151 assert!(memory.dedup_gardener_enabled);
9152 assert_eq!(memory.dedup_gardener_min_score, 0.7);
9153 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9154 assert_eq!(memory.memory_active_capacity, 500);
9155 assert_eq!(memory.capacity_max_archivals_per_run, 10);
9156 assert!(!memory.granularity_freshness_gardener_enabled);
9157 }
9158
9159 #[test]
9161 fn memory_active_capacity_defaults_off() {
9162 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9163 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9164 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9165 let memory = parsed.memory.as_ref().unwrap();
9166 assert_eq!(memory.memory_active_capacity, 0);
9167 assert_eq!(
9168 memory.capacity_max_archivals_per_run, 50,
9169 "omitted field takes the serde default fn"
9170 );
9171 }
9172
9173 #[test]
9174 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9175 let defaults = MemoryConfig::default();
9176 assert_eq!(defaults.summary_target_ratio, 0.20);
9177 assert_eq!(defaults.summary_safe_window_percent, 80);
9178
9179 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9180 let memory = parsed.memory.as_ref().expect("memory present");
9181 assert_eq!(memory.summary_target_ratio, 0.20);
9182 assert_eq!(memory.summary_safe_window_percent, 80);
9183 }
9184
9185 #[test]
9189 fn memory_maintenance_integrators_default_on() {
9190 let defaults = MemoryConfig::default();
9191 assert!(defaults.auto_dream_enabled);
9192 assert!(defaults.gardener_enabled);
9193 assert!(defaults.dedup_gardener_enabled);
9194 assert_eq!(defaults.gardener_volume_trigger, 25);
9195
9196 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9198 let memory = parsed.memory.as_ref().expect("memory present");
9199 assert!(
9200 memory.auto_dream_enabled,
9201 "auto_dream on when field omitted"
9202 );
9203 assert!(memory.gardener_enabled, "gardener on when field omitted");
9204 assert!(
9205 memory.dedup_gardener_enabled,
9206 "dedup gardener on when field omitted"
9207 );
9208 let opted_out: Config =
9210 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9211 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9212 }
9213
9214 #[test]
9215 fn memory_config_env_overrides_prompt_flags() {
9216 let _lock = env_lock_acquire();
9217 let temp_home = TempHome::new();
9218 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9219 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9220 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9221 let _relevant_recall_rerank =
9222 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9223 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9224
9225 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9226 let memory = config
9227 .memory
9228 .as_ref()
9229 .expect("memory config should be created by env overrides");
9230 assert!(!memory.project_prompt_injection);
9231 assert!(!memory.relevant_recall);
9232 assert!(memory.relevant_recall_rerank);
9233 assert!(!memory.project_first_dream);
9234 }
9235
9236 #[test]
9237 fn provider_api_keys_injected_from_env_and_never_persisted() {
9238 let _lock = env_lock_acquire();
9239 let temp_home = TempHome::new();
9240 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9241 let _anthropic = crate::test_support::override_runtime_env_var(
9242 "BAMBOO_ANTHROPIC_API_KEY",
9243 Some("sk-ant-from-env"),
9244 );
9245 let _openai = crate::test_support::override_runtime_env_var(
9246 "BAMBOO_OPENAI_API_KEY",
9247 Some("sk-oai-from-env"),
9248 );
9249 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9250
9251 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9254 assert_eq!(
9255 config
9256 .providers
9257 .anthropic
9258 .as_ref()
9259 .expect("anthropic created from env")
9260 .api_key,
9261 "sk-ant-from-env"
9262 );
9263 assert_eq!(
9264 config
9265 .providers
9266 .openai
9267 .as_ref()
9268 .expect("openai created from env")
9269 .api_key,
9270 "sk-oai-from-env"
9271 );
9272 assert!(config.providers.gemini.is_none());
9274
9275 config
9281 .save_to_dir(temp_home.path.clone())
9282 .expect("save config");
9283 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9284 .expect("read persisted config.json");
9285 assert!(
9286 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9287 "env key must not be persisted as plaintext"
9288 );
9289 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9290 assert!(
9291 disk_json["providers"]["anthropic"]
9292 .get("api_key_encrypted")
9293 .is_none(),
9294 "env-sourced anthropic key must not be re-encrypted into config.json"
9295 );
9296 assert!(
9297 disk_json["providers"]["openai"]
9298 .get("api_key_encrypted")
9299 .is_none(),
9300 "env-sourced openai key must not be re-encrypted into config.json"
9301 );
9302
9303 drop(_anthropic);
9306 drop(_openai);
9307 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9308 assert!(reloaded
9309 .providers
9310 .anthropic
9311 .as_ref()
9312 .map(|a| a.api_key.is_empty())
9313 .unwrap_or(true));
9314 }
9315
9316 #[test]
9317 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9318 let _lock = env_lock_acquire();
9319 let temp_home = TempHome::new();
9320 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9321 let target = temp_home.path.join("workspace-default");
9322 std::fs::create_dir_all(&target).expect("default work area dir should exist");
9323
9324 let mut config = Config::default();
9325 config.default_work_area.replace(DefaultWorkAreaConfig {
9326 path: Some("~/workspace-default".to_string()),
9327 });
9328
9329 assert_eq!(config.get_default_work_area_path(), Some(target));
9330 }
9331
9332 #[test]
9333 fn get_default_work_area_path_returns_none_for_missing_directory() {
9334 let _lock = env_lock_acquire();
9335 let temp_home = TempHome::new();
9336 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9337
9338 let mut config = Config::default();
9339 config.default_work_area.replace(DefaultWorkAreaConfig {
9340 path: Some("~/missing-default-work-area".to_string()),
9341 });
9342
9343 assert!(config.get_default_work_area_path().is_none());
9344 }
9345
9346 #[test]
9347 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9348 let mut config = Config::default();
9349 config.tools.disabled = vec![
9350 " read_file ".to_string(),
9351 "".to_string(),
9352 "read_file".to_string(),
9353 "bash".to_string(),
9354 "default::getCurrentDir".to_string(),
9355 "default::applyPatch".to_string(),
9356 "default::custom_tool".to_string(),
9357 "mcp__alpha__inspect".to_string(),
9358 ];
9359
9360 config.normalize_tool_settings();
9361
9362 assert_eq!(
9363 config.tools.disabled,
9364 vec![
9365 "bash",
9366 "default::applyPatch",
9367 "default::custom_tool",
9368 "default::getCurrentDir",
9369 "mcp__alpha__inspect",
9370 "read_file"
9371 ]
9372 );
9373 }
9374
9375 #[test]
9376 fn config_load_preserves_disabled_references_for_catalog_resolution() {
9377 let _lock = env_lock_acquire();
9378 let temp_home = TempHome::new();
9379 temp_home.set_config_json(
9380 r#"{
9381 "tools": {
9382 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9383 }
9384}"#,
9385 );
9386
9387 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9388 assert_eq!(
9389 config.tools.disabled,
9390 vec!["bash", "default::getCurrentDir", "read_file"]
9391 );
9392 assert!(config.disabled_tool_references().contains("bash"));
9393 assert!(config.disabled_tool_references().contains("read_file"));
9394 assert!(config
9395 .disabled_tool_references()
9396 .contains("default::getCurrentDir"));
9397 assert_eq!(
9398 config.disabled_tool_names(),
9399 BTreeSet::from([
9400 "Bash".to_string(),
9401 "GetCurrentDir".to_string(),
9402 "Read".to_string()
9403 ])
9404 );
9405 }
9406
9407 #[test]
9408 fn normalize_skill_settings_trims_dedupes_and_sorts() {
9409 let mut config = Config::default();
9410 config.skills.disabled = vec![
9411 " pdf ".to_string(),
9412 "".to_string(),
9413 "pdf".to_string(),
9414 "skill-creator".to_string(),
9415 ];
9416
9417 config.normalize_skill_settings();
9418
9419 assert_eq!(
9420 config.skills.disabled,
9421 vec!["pdf".to_string(), "skill-creator".to_string()]
9422 );
9423 }
9424
9425 #[test]
9426 fn config_load_reads_disabled_skills_as_normalized_ids() {
9427 let _lock = env_lock_acquire();
9428 let temp_home = TempHome::new();
9429 temp_home.set_config_json(
9430 r#"{
9431 "skills": {
9432 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9433 }
9434}"#,
9435 );
9436
9437 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9438 assert_eq!(
9439 config.skills.disabled,
9440 vec!["pdf".to_string(), "skill-creator".to_string()]
9441 );
9442 assert!(config.disabled_skill_ids().contains("pdf"));
9443 assert!(config.disabled_skill_ids().contains("skill-creator"));
9444 }
9445
9446 #[test]
9447 fn test_server_config_defaults() {
9448 let _lock = env_lock_acquire();
9449 let temp_home = TempHome::new();
9450
9451 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9452 assert_eq!(config.server.port, 9562);
9453 assert_eq!(config.server.bind, "127.0.0.1");
9454 assert_eq!(config.server.workers, 10);
9455 assert!(config.server.static_dir.is_none());
9456 }
9457
9458 #[test]
9459 fn test_server_addr() {
9460 let mut config = Config::default();
9461 config.server.port = 9000;
9462 config.server.bind = "0.0.0.0".to_string();
9463 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9464 }
9465
9466 #[test]
9467 fn test_env_var_overrides() {
9468 let _lock = env_lock_acquire();
9469 let temp_home = TempHome::new();
9470
9471 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9472 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9473 let _provider =
9474 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9475
9476 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9477 assert_eq!(config.server.port, 9999);
9478 assert_eq!(config.server.bind, "192.168.1.1");
9479 assert_eq!(config.provider, "openai");
9480 }
9481
9482 #[test]
9483 fn test_config_save_and_load() {
9484 let _lock = env_lock_acquire();
9485 let temp_home = TempHome::new();
9486
9487 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9488 config.server.port = 9000;
9489 config.server.bind = "0.0.0.0".to_string();
9490 config.provider = "anthropic".to_string();
9491
9492 config
9494 .save_to_dir(temp_home.path.clone())
9495 .expect("Failed to save config");
9496
9497 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9499
9500 assert_eq!(loaded.server.port, 9000);
9502 assert_eq!(loaded.server.bind, "0.0.0.0");
9503 assert_eq!(loaded.provider, "anthropic");
9504 }
9505
9506 #[test]
9507 fn modular_root_and_public_serde_preserve_legacy_shape() {
9508 let _lock = env_lock_acquire();
9509 let temp_home = TempHome::new();
9510 let input = serde_json::json!({
9511 "http_proxy": "http://proxy.example",
9512 "provider": "openai",
9513 "mcp": { "servers": [] },
9514 "future_extension": { "enabled": true },
9515 "memory": { "background_model": "memory-model" },
9516 "subagents": { "max_concurrent": 7 },
9517 "providers": { "openai": { "model": "chat-model" } }
9518 });
9519
9520 let config: Config = serde_json::from_value(input).unwrap();
9521 let public = serde_json::to_value(&config).unwrap();
9522 assert_eq!(public["http_proxy"], "http://proxy.example");
9523 assert!(public.get("mcp").is_none());
9524 assert!(public.get("mcpServers").is_some());
9525 assert_eq!(public["future_extension"]["enabled"], true);
9526 assert_eq!(public["memory"]["background_model"], "memory-model");
9527 assert_eq!(public["subagents"]["max_concurrent"], 7);
9528 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9529
9530 let round_tripped: Config = serde_json::from_value(public).unwrap();
9531 assert_eq!(
9532 round_tripped
9533 .memory()
9534 .as_ref()
9535 .unwrap()
9536 .background_model
9537 .as_deref(),
9538 Some("memory-model")
9539 );
9540 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9541 assert_eq!(
9542 round_tripped
9543 .providers()
9544 .openai
9545 .as_ref()
9546 .unwrap()
9547 .model
9548 .as_deref(),
9549 Some("chat-model")
9550 );
9551 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9552
9553 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9554 let persisted: Value =
9555 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9556 .unwrap();
9557 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9558 assert!(persisted.get("mcpServers").is_some());
9559 assert_eq!(persisted["future_extension"]["enabled"], true);
9560 assert!(persisted.get("memory").is_none());
9561 assert!(persisted.get("subagents").is_none());
9562 assert!(persisted.get("providers").is_none());
9563 for internal_section_name in [
9564 "network",
9565 "provider_routing",
9566 "model_behavior",
9567 "tooling",
9568 "workspace",
9569 "execution",
9570 "integrations",
9571 "plugin_security",
9572 ] {
9573 assert!(persisted.get(internal_section_name).is_none());
9574 }
9575 }
9576
9577 #[test]
9578 fn config_decrypts_proxy_auth_from_encrypted_field() {
9579 let _lock = env_lock_acquire();
9580 let temp_home = TempHome::new();
9581
9582 let key_guard = crate::encryption::set_test_encryption_key([
9584 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9585 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9586 0x1c, 0x1d, 0x1e, 0x1f,
9587 ]);
9588
9589 let auth = ProxyAuth {
9590 username: "user".to_string(),
9591 password: "pass".to_string(),
9592 };
9593 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9594 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9595
9596 temp_home.set_config_json(&format!(
9597 r#"{{
9598 "http_proxy": "http://proxy.example.com:8080",
9599 "proxy_auth_encrypted": "{encrypted}"
9600}}"#
9601 ));
9602 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9603 let loaded_auth = config
9604 .proxy_auth
9605 .as_ref()
9606 .expect("proxy auth should be hydrated");
9607 assert_eq!(loaded_auth.username, "user");
9608 assert_eq!(loaded_auth.password, "pass");
9609 drop(key_guard);
9610 }
9611
9612 #[test]
9613 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9614 let _lock = env_lock_acquire();
9615 let temp_home = TempHome::new();
9616
9617 let key_guard = crate::encryption::set_test_encryption_key([
9619 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9620 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9621 0x1c, 0x1d, 0x1e, 0x1f,
9622 ]);
9623
9624 let auth = ProxyAuth {
9625 username: "user".to_string(),
9626 password: "pass".to_string(),
9627 };
9628 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9629 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9630
9631 temp_home.set_config_json(&format!(
9633 r#"{{
9634 "http_proxy": "http://proxy.example.com:8080",
9635 "http_proxy_auth_encrypted": "{encrypted}",
9636 "https_proxy_auth_encrypted": "{encrypted}"
9637}}"#
9638 ));
9639
9640 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9641 let loaded_auth = config
9642 .proxy_auth
9643 .as_ref()
9644 .expect("proxy auth should be hydrated");
9645 assert_eq!(loaded_auth.username, "user");
9646 assert_eq!(loaded_auth.password, "pass");
9647 drop(key_guard);
9648 }
9649
9650 #[test]
9651 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9652 let _lock = env_lock_acquire();
9653 let temp_home = TempHome::new();
9654
9655 let key_guard = crate::encryption::set_test_encryption_key([
9657 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9658 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9659 0x1c, 0x1d, 0x1e, 0x1f,
9660 ]);
9661
9662 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9663 config.proxy_auth = Some(ProxyAuth {
9664 username: "user".to_string(),
9665 password: "pass".to_string(),
9666 });
9667 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9668 assert!(error
9669 .to_string()
9670 .contains("isolated credential transaction"));
9671 let path = temp_home.path.join("config.json");
9672 assert!(
9673 !path.exists(),
9674 "a rejected unisolated secret must not create config.json"
9675 );
9676 drop(key_guard);
9677 }
9678
9679 #[test]
9680 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9681 let _lock = env_lock_acquire();
9682 let temp_home = TempHome::new();
9683 let mut config = Config::default();
9684 config.env_vars.push(EnvVarEntry {
9685 name: "TOKEN".to_string(),
9686 value: String::new(),
9687 secret: true,
9688 value_encrypted: None,
9689 credential_ref: None,
9690 configured: true,
9691 description: None,
9692 });
9693 let path = temp_home.path.join("config.json");
9694
9695 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9696 assert!(error
9697 .to_string()
9698 .contains("isolated credential transaction"));
9699 assert!(
9700 !path.exists(),
9701 "a rejected dangling ref must not create config.json"
9702 );
9703
9704 let original = br#"{"preserve":"original"}"#;
9705 std::fs::write(&path, original).unwrap();
9706 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9707 assert_eq!(
9708 std::fs::read(&path).unwrap(),
9709 original,
9710 "a rejected dangling ref must not modify an existing config.json"
9711 );
9712 }
9713
9714 #[test]
9715 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9716 let _lock = env_lock_acquire();
9717 let temp_home = TempHome::new();
9718
9719 let key_guard = crate::encryption::set_test_encryption_key([
9721 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9722 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9723 0x1c, 0x1d, 0x1e, 0x1f,
9724 ]);
9725
9726 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9727 crate::CredentialStore::open(&temp_home.path)
9728 .replace(
9729 reference.clone(),
9730 "sk-test-provider-key",
9731 crate::CredentialSource::User,
9732 0,
9733 )
9734 .unwrap();
9735 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9736 config.provider = "openai".to_string();
9737 config.providers.openai = Some(OpenAIConfig {
9738 api_key: "sk-test-provider-key".to_string(),
9739 api_key_encrypted: None,
9740 credential_ref: Some(reference),
9741 base_url: None,
9742 model: None,
9743 fast_model: None,
9744 vision_model: None,
9745 reasoning_effort: None,
9746 responses_only_models: vec![],
9747 request_overrides: None,
9748 extra: Default::default(),
9749 api_key_from_env: false,
9750 });
9751
9752 config
9753 .save_to_dir(temp_home.path.clone())
9754 .expect("save should persist provider reference");
9755
9756 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9757 .expect("read providers.json");
9758 assert!(
9759 !content.contains("api_key_encrypted"),
9760 "providers.json must not store provider ciphertext"
9761 );
9762 assert!(
9763 !content.contains("\"api_key\""),
9764 "providers.json should not store plaintext provider keys"
9765 );
9766
9767 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9768 let openai = loaded
9769 .providers
9770 .openai
9771 .as_ref()
9772 .expect("openai config should be present");
9773 assert_eq!(openai.api_key, "sk-test-provider-key");
9774
9775 drop(key_guard);
9776 }
9777
9778 #[test]
9779 fn config_save_persists_mcp_servers_in_mainstream_format() {
9780 let _lock = env_lock_acquire();
9781 let temp_home = TempHome::new();
9782
9783 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9784
9785 let mut env = std::collections::HashMap::new();
9786 env.insert("TOKEN".to_string(), "supersecret".to_string());
9787
9788 config.mcp.servers = vec![
9789 bamboo_domain::mcp_config::McpServerConfig {
9790 id: "stdio-secret".to_string(),
9791 name: None,
9792 enabled: true,
9793 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9794 bamboo_domain::mcp_config::StdioConfig {
9795 command: "echo".to_string(),
9796 args: vec![],
9797 cwd: None,
9798 env,
9799 env_encrypted: std::collections::HashMap::new(),
9800 env_credential_refs: std::collections::HashMap::new(),
9801 startup_timeout_ms: 5000,
9802 },
9803 ),
9804 request_timeout_ms: 5000,
9805 healthcheck_interval_ms: 1000,
9806 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9807 allowed_tools: vec![],
9808 denied_tools: vec![],
9809 },
9810 bamboo_domain::mcp_config::McpServerConfig {
9811 id: "http-secret".to_string(),
9812 name: None,
9813 enabled: true,
9814 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9815 bamboo_domain::mcp_config::StreamableHttpConfig {
9816 url: "http://localhost:8080/http".to_string(),
9817 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9818 name: "Authorization".to_string(),
9819 value: "Bearer token123".to_string(),
9820 value_encrypted: None,
9821 credential_ref: None,
9822 }],
9823 connect_timeout_ms: 5000,
9824 },
9825 ),
9826 request_timeout_ms: 5000,
9827 healthcheck_interval_ms: 1000,
9828 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9829 allowed_tools: vec![],
9830 denied_tools: vec![],
9831 },
9832 ];
9833
9834 config
9835 .save_to_dir(temp_home.path.clone())
9836 .expect("save should persist MCP servers");
9837
9838 let content =
9839 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9840 assert!(
9841 content.contains("\"mcpServers\""),
9842 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9843 );
9844 assert!(
9845 content.contains("supersecret"),
9846 "config.json should persist MCP stdio env in mainstream format"
9847 );
9848 assert!(
9849 content.contains("Bearer token123"),
9850 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9851 );
9852 assert!(
9853 !content.contains("\"env_encrypted\""),
9854 "config.json should not persist legacy env_encrypted fields"
9855 );
9856 assert!(
9857 !content.contains("\"value_encrypted\""),
9858 "config.json should not persist legacy value_encrypted fields"
9859 );
9860
9861 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9862 let stdio = loaded
9863 .mcp
9864 .servers
9865 .iter()
9866 .find(|s| s.id == "stdio-secret")
9867 .expect("stdio server should exist");
9868 match &stdio.transport {
9869 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9870 assert_eq!(
9871 stdio.env.get("TOKEN").map(|s| s.as_str()),
9872 Some("supersecret")
9873 );
9874 }
9875 _ => panic!("Expected stdio transport"),
9876 }
9877
9878 let http = loaded
9879 .mcp
9880 .servers
9881 .iter()
9882 .find(|s| s.id == "http-secret")
9883 .expect("http server should exist");
9884 match &http.transport {
9885 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9886 assert_eq!(http.headers[0].value, "Bearer token123");
9887 }
9888 _ => panic!("Expected Streamable HTTP transport"),
9889 }
9890 }
9891
9892 #[test]
9893 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9894 let _lock = env_lock_acquire();
9895 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9896 let temp_home = TempHome::new();
9897 let env_plaintext = "mcp-root-env-plaintext-597";
9898 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9899 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9900 std::fs::write(
9901 temp_home.path.join("config.json"),
9902 serde_json::to_vec_pretty(&serde_json::json!({
9903 "features": {"provider_model_ref": true},
9904 "mcpServers": {
9905 "stdio-root": {
9906 "command": "unused-disabled-command",
9907 "env": {"TOKEN": env_plaintext},
9908 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9909 "env_credential_refs": {
9910 "TOKEN": "mcp.stdio-root.env_TOKEN"
9911 }
9912 },
9913 "http-root": {
9914 "url": "https://example.test/mcp",
9915 "transport_kind": "streamable_http",
9916 "headers": {"Authorization": header_plaintext},
9917 "header_credential_refs": {
9918 "Authorization": "mcp.http-root.header_Authorization"
9919 }
9920 }
9921 }
9922 }))
9923 .unwrap(),
9924 )
9925 .unwrap();
9926 std::fs::write(
9927 temp_home.path.join("mcp.json"),
9928 serde_json::to_vec_pretty(&serde_json::json!({
9929 "schema_version": 1,
9930 "revision": 7,
9931 "data": {
9932 "stdio-root": {
9933 "command": "unused-disabled-command",
9934 "enabled": false,
9935 "env_encrypted": {"TOKEN": legacy_ciphertext},
9936 "request_timeout_ms": 100,
9937 "healthcheck_interval_ms": 100
9938 },
9939 "http-root": {
9940 "url": "https://example.test/mcp",
9941 "transport_kind": "streamable_http",
9942 "enabled": false,
9943 "headers": {"Authorization": header_plaintext},
9944 "request_timeout_ms": 100,
9945 "healthcheck_interval_ms": 100
9946 }
9947 }
9948 }))
9949 .unwrap(),
9950 )
9951 .unwrap();
9952
9953 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9954 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9955 temp_home.path.join("mcp.json"),
9956 1,
9957 )
9958 .load()
9959 .unwrap()
9960 .unwrap();
9961 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9962 config.mcp = stored.data;
9963 config
9964 .hydrate_mcp_credentials_from_store(&temp_home.path)
9965 .unwrap();
9966
9967 let public = serde_json::to_value(&config.mcp).unwrap();
9968 let rendered_public = public.to_string();
9969 assert!(rendered_public.contains(env_plaintext));
9970 assert!(rendered_public.contains(header_plaintext));
9971 let compatible: bamboo_domain::mcp_config::McpConfig =
9972 serde_json::from_value(public).unwrap();
9973 assert_eq!(compatible.servers.len(), 2);
9974
9975 config.features.provider_model_ref = !config.features.provider_model_ref;
9976 config.save_to_dir(temp_home.path.clone()).unwrap();
9977 config.features.provider_model_ref = !config.features.provider_model_ref;
9978 config.save_to_dir(temp_home.path.clone()).unwrap();
9979
9980 for entry in std::fs::read_dir(&temp_home.path)
9981 .unwrap()
9982 .filter_map(Result::ok)
9983 {
9984 let name = entry.file_name().to_string_lossy().to_string();
9985 if name == "credentials.json" || entry.path().is_dir() {
9986 continue;
9987 }
9988 if name == "config.json"
9989 || name == "mcp.json"
9990 || name.contains(".bak")
9991 || name.starts_with("config-credential-migration")
9992 {
9993 let bytes = std::fs::read(entry.path()).unwrap();
9994 let content = String::from_utf8_lossy(&bytes);
9995 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9996 assert!(
9997 !content.contains(header_plaintext),
9998 "secret leaked to {name}"
9999 );
10000 assert!(
10001 !content.contains(&legacy_ciphertext),
10002 "legacy ciphertext leaked to {name}"
10003 );
10004 }
10005 }
10006 }
10007
10008 #[test]
10011 fn env_vars_as_map_includes_only_non_empty_values() {
10012 let mut config = Config::default();
10013 config.env_vars.extend([
10014 EnvVarEntry {
10015 name: "A".to_string(),
10016 value: "val_a".to_string(),
10017 secret: false,
10018 value_encrypted: None,
10019 credential_ref: None,
10020 configured: true,
10021 description: None,
10022 },
10023 EnvVarEntry {
10024 name: "B".to_string(),
10025 value: "".to_string(), secret: true,
10027 value_encrypted: None,
10028 credential_ref: None,
10029 configured: false,
10030 description: None,
10031 },
10032 EnvVarEntry {
10033 name: "C".to_string(),
10034 value: " ".to_string(), secret: false,
10036 value_encrypted: None,
10037 credential_ref: None,
10038 configured: true,
10039 description: None,
10040 },
10041 EnvVarEntry {
10042 name: "D".to_string(),
10043 value: "val_d".to_string(),
10044 secret: true,
10045 value_encrypted: Some("enc".to_string()),
10046 credential_ref: None,
10047 configured: true,
10048 description: Some("desc".to_string()),
10049 },
10050 ]);
10051
10052 let map = config.env_vars_as_map();
10053 assert_eq!(map.len(), 2);
10054 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10055 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10056 assert!(!map.contains_key("B"));
10057 assert!(!map.contains_key("C"));
10058 }
10059
10060 #[test]
10061 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10062 let mut config = Config::default();
10063 config.env_vars.extend([
10064 EnvVarEntry {
10065 name: "PLAIN".to_string(),
10066 value: "visible".to_string(),
10067 secret: false,
10068 value_encrypted: None,
10069 credential_ref: None,
10070 configured: true,
10071 description: None,
10072 },
10073 EnvVarEntry {
10074 name: "SECRET".to_string(),
10075 value: "hidden_value".to_string(),
10076 secret: true,
10077 value_encrypted: Some("enc_data".to_string()),
10078 credential_ref: None,
10079 configured: true,
10080 description: None,
10081 },
10082 ]);
10083
10084 config.sanitize_env_vars_for_disk();
10085
10086 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
10089
10090 #[test]
10091 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10092 let mut config = Config::default();
10093 config.env_vars.extend([
10094 EnvVarEntry {
10095 name: "OPEN".to_string(),
10096 value: "val".to_string(),
10097 secret: false,
10098 value_encrypted: None,
10099 credential_ref: None,
10100 configured: true,
10101 description: None,
10102 },
10103 EnvVarEntry {
10104 name: "HIDDEN".to_string(),
10105 value: "real_secret".to_string(),
10106 secret: true,
10107 value_encrypted: Some("enc".to_string()),
10108 credential_ref: None,
10109 configured: true,
10110 description: None,
10111 },
10112 ]);
10113
10114 config.sanitize_env_vars_for_disk();
10115
10116 assert_eq!(config.env_vars[0].value, "val");
10118 assert_eq!(config.env_vars[1].value, "");
10120 assert!(config.env_vars[1].value_encrypted.is_none());
10121 }
10122
10123 #[test]
10124 fn legacy_env_ciphertext_is_read_but_never_serialized() {
10125 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10126 let mut config: Config = serde_json::from_value(serde_json::json!({
10127 "env_vars": [{
10128 "name": "TOKEN",
10129 "secret": true,
10130 "value_encrypted": ciphertext
10131 }]
10132 }))
10133 .unwrap();
10134 config.hydrate_env_vars_from_encrypted();
10135 assert_eq!(config.env_vars[0].value, "my-secret-token");
10136 let serialized = serde_json::to_value(&config).unwrap();
10137 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10138 }
10139
10140 #[test]
10141 fn configured_env_ref_missing_from_store_fails_closed() {
10142 let dir = tempfile::tempdir().unwrap();
10143 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10144 let mut config = Config::default();
10145 config.env_vars.push(EnvVarEntry {
10146 name: "TOKEN".to_string(),
10147 value: String::new(),
10148 secret: true,
10149 value_encrypted: None,
10150 credential_ref: Some(reference),
10151 configured: true,
10152 description: None,
10153 });
10154 let error = config
10155 .hydrate_env_var_credentials_from_store(dir.path())
10156 .unwrap_err();
10157 assert!(error
10158 .to_string()
10159 .contains("referenced env credential is unavailable"));
10160 assert!(config.env_vars[0].configured);
10161 assert!(config.env_vars[0].value.is_empty());
10162 }
10163
10164 #[test]
10165 fn configured_notification_ref_missing_from_store_fails_closed() {
10166 let dir = tempfile::tempdir().unwrap();
10167 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10168 let mut config = Config::default();
10169 config.notifications.ntfy.credential_ref = Some(reference);
10170 config.notifications.ntfy.configured = true;
10171
10172 let error = config
10173 .hydrate_notification_credentials_from_store(dir.path())
10174 .unwrap_err();
10175
10176 assert!(error
10177 .to_string()
10178 .contains("referenced ntfy credential is unavailable"));
10179 assert!(config.notifications.ntfy.configured);
10180 assert!(config.notifications.ntfy.token.is_none());
10181 }
10182
10183 #[test]
10184 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10185 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10186 let dir = tempfile::tempdir().unwrap();
10187 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10188 crate::CredentialStore::open(dir.path())
10189 .replace(
10190 reference.clone(),
10191 "exclusive-notification-secret",
10192 crate::CredentialSource::User,
10193 0,
10194 )
10195 .unwrap();
10196
10197 let mut config = Config::default();
10198 config.notifications.ntfy.credential_ref = Some(reference.clone());
10199 config.notifications.ntfy.configured = true;
10200 config.proxy_auth_credential_ref = Some(reference.clone());
10201 let error = config
10202 .hydrate_notification_credentials_from_store(dir.path())
10203 .unwrap_err();
10204 let rendered = error.to_string();
10205 assert!(rendered
10206 .contains("notification credential reference is shared by another config consumer"));
10207 assert!(!rendered.contains(reference.as_str()));
10208 assert!(config.notifications.ntfy.token.is_none());
10209
10210 config.proxy_auth_credential_ref = None;
10211 config
10212 .hydrate_notification_credentials_from_store(dir.path())
10213 .unwrap();
10214 assert_eq!(
10215 config.notifications.ntfy.token.as_deref(),
10216 Some("exclusive-notification-secret")
10217 );
10218 }
10219
10220 #[test]
10221 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10222 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10223 let dir = tempfile::tempdir().unwrap();
10224 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10225 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10226 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10227 "notifications": {
10228 "ntfy": { "enabled": true, "token_encrypted": ntfy },
10229 "bark": { "enabled": true, "device_key_encrypted": bark }
10230 }
10231 }))
10232 .unwrap();
10233 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10234 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10235
10236 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10237
10238 assert!(loaded.notifications.ntfy.token.is_none());
10239 assert!(loaded.notifications.bark.device_key.is_none());
10240 assert_eq!(
10241 std::fs::read(dir.path().join("config.json")).unwrap(),
10242 bytes
10243 );
10244 }
10245
10246 #[test]
10247 fn publish_and_current_env_vars_round_trip() {
10248 let _lock = crate::test_support::env_cache_lock_acquire();
10258 let mut config = Config::default();
10259 config.env_vars.extend([EnvVarEntry {
10260 name: "TEST_PUBLISH".to_string(),
10261 value: "pub_value".to_string(),
10262 secret: false,
10263 value_encrypted: None,
10264 credential_ref: None,
10265 configured: true,
10266 description: None,
10267 }]);
10268
10269 config.publish_env_vars();
10270 assert_eq!(
10271 Config::current_env_vars()
10272 .get("TEST_PUBLISH")
10273 .map(String::as_str),
10274 Some("pub_value")
10275 );
10276 }
10277
10278 #[test]
10279 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10280 let mut config = Config::default();
10281 config.subagents.broker = Some(BrokerClientConfig {
10282 endpoint: "ws://127.0.0.1:9600".to_string(),
10283 token: "super-secret-token".to_string(),
10284 token_encrypted: None,
10285 credential_ref: None,
10286 configured: false,
10287 });
10288
10289 config.refresh_broker_token_encrypted().unwrap();
10291 config.sanitize_broker_token_for_disk();
10292 let broker = config.subagents.broker.as_ref().unwrap();
10293 assert!(broker.token.is_empty(), "plaintext cleared for disk");
10294 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10295 assert_ne!(
10296 broker.token_encrypted.as_deref(),
10297 Some("super-secret-token")
10298 );
10299
10300 config.hydrate_broker_token_from_encrypted();
10302 assert_eq!(
10303 config.subagents.broker.as_ref().unwrap().token,
10304 "super-secret-token"
10305 );
10306 }
10307
10308 #[test]
10309 fn broker_token_empty_refresh_preserves_ciphertext() {
10310 let mut config = Config::default();
10312 config.subagents.broker = Some(BrokerClientConfig {
10313 endpoint: "ws://h:9600".to_string(),
10314 token: String::new(),
10315 token_encrypted: Some("existing-cipher".to_string()),
10316 credential_ref: None,
10317 configured: false,
10318 });
10319 config.refresh_broker_token_encrypted().unwrap();
10320 assert_eq!(
10321 config
10322 .subagents
10323 .broker
10324 .as_ref()
10325 .unwrap()
10326 .token_encrypted
10327 .as_deref(),
10328 Some("existing-cipher"),
10329 );
10330 }
10331
10332 #[test]
10333 fn notifications_config_defaults_when_key_missing() {
10334 let config: Config = serde_json::from_str("{}").expect("empty object parses");
10337 assert_eq!(config.notifications, NotificationsConfig::default());
10338 assert_eq!(config.notifications.desktop.enabled, None);
10339 assert!(!config.notifications.ntfy.enabled);
10340 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10341 assert_eq!(config.notifications.ntfy.token, None);
10342 assert!(!config.notifications.bark.enabled);
10343 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10344 assert_eq!(config.notifications.bark.device_key, None);
10345 }
10346
10347 #[test]
10348 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10349 let mut config = Config::default();
10350 config.notifications.ntfy = NtfyChannelConfig {
10351 enabled: true,
10352 base_url: "https://ntfy.sh".to_string(),
10353 topic: "bamboo-alerts".to_string(),
10354 token: Some("tk_super_secret".to_string()),
10355 token_encrypted: None,
10356 credential_ref: None,
10357 configured: false,
10358 };
10359
10360 config.refresh_notifications_encrypted().unwrap();
10363 assert!(config.notifications.ntfy.token_encrypted.is_some());
10364 assert_ne!(
10365 config.notifications.ntfy.token_encrypted.as_deref(),
10366 Some("tk_super_secret")
10367 );
10368
10369 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10371 assert!(
10372 !json.contains("tk_super_secret"),
10373 "plaintext token must never be serialized"
10374 );
10375 assert!(!json.contains("token_encrypted"));
10376
10377 config.notifications.ntfy.token = None;
10379 config.hydrate_notifications_from_encrypted();
10380 assert_eq!(
10381 config.notifications.ntfy.token.as_deref(),
10382 Some("tk_super_secret")
10383 );
10384 }
10385
10386 #[test]
10387 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10388 let mut config = Config::default();
10389 config.notifications.bark = BarkChannelConfig {
10390 enabled: true,
10391 base_url: "https://api.day.app".to_string(),
10392 device_key: Some("dk_super_secret".to_string()),
10393 device_key_encrypted: None,
10394 credential_ref: None,
10395 configured: false,
10396 };
10397
10398 config.refresh_notifications_encrypted().unwrap();
10399 assert!(config.notifications.bark.device_key_encrypted.is_some());
10400 assert_ne!(
10401 config.notifications.bark.device_key_encrypted.as_deref(),
10402 Some("dk_super_secret")
10403 );
10404
10405 let json = serde_json::to_string(&config.notifications.bark).unwrap();
10406 assert!(
10407 !json.contains("dk_super_secret"),
10408 "plaintext device key must never be serialized"
10409 );
10410 assert!(!json.contains("device_key_encrypted"));
10411
10412 config.notifications.bark.device_key = None;
10413 config.hydrate_notifications_from_encrypted();
10414 assert_eq!(
10415 config.notifications.bark.device_key.as_deref(),
10416 Some("dk_super_secret")
10417 );
10418 }
10419
10420 #[test]
10421 fn notification_secrets_empty_refresh_preserves_ciphertext() {
10422 let mut config = Config::default();
10425 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10426 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10427
10428 config.refresh_notifications_encrypted().unwrap();
10429
10430 assert_eq!(
10431 config.notifications.ntfy.token_encrypted.as_deref(),
10432 Some("existing-ntfy-cipher")
10433 );
10434 assert_eq!(
10435 config.notifications.bark.device_key_encrypted.as_deref(),
10436 Some("existing-bark-cipher")
10437 );
10438 }
10439
10440 #[test]
10441 fn hydrate_skips_non_secret_entries() {
10442 let mut config = Config::default();
10443 config.env_vars.extend([EnvVarEntry {
10444 name: "PLAIN".to_string(),
10445 value: "original".to_string(),
10446 secret: false,
10447 value_encrypted: Some("should-be-ignored".to_string()),
10448 credential_ref: None,
10449 configured: true,
10450 description: None,
10451 }]);
10452
10453 config.hydrate_env_vars_from_encrypted();
10454 assert_eq!(config.env_vars[0].value, "original");
10456 }
10457
10458 #[test]
10459 fn default_config_has_empty_env_vars() {
10460 assert!(Config::default().env_vars.is_empty());
10465 }
10466
10467 #[test]
10468 fn serde_round_trip_with_env_vars() {
10469 let mut config = Config::default();
10470 config.env_vars.extend([
10471 EnvVarEntry {
10472 name: "KEY1".to_string(),
10473 value: "val1".to_string(),
10474 secret: false,
10475 value_encrypted: None,
10476 credential_ref: None,
10477 configured: true,
10478 description: Some("First key".to_string()),
10479 },
10480 EnvVarEntry {
10481 name: "KEY2".to_string(),
10482 value: "".to_string(), secret: true,
10484 value_encrypted: Some("enc123".to_string()),
10485 credential_ref: None,
10486 configured: true,
10487 description: None,
10488 },
10489 ]);
10490
10491 let json = serde_json::to_string(&config).unwrap();
10492 let restored: Config = serde_json::from_str(&json).unwrap();
10493
10494 assert_eq!(restored.env_vars.len(), 2);
10495 assert_eq!(restored.env_vars[0].name, "KEY1");
10496 assert_eq!(restored.env_vars[0].value, "val1");
10497 assert!(!restored.env_vars[0].secret);
10498 assert_eq!(restored.env_vars[1].name, "KEY2");
10499 assert!(restored.env_vars[1].secret);
10500 assert!(restored.env_vars[1].value_encrypted.is_none());
10501 }
10502
10503 #[test]
10506 #[allow(clippy::field_reassign_with_default)]
10508 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10509 let mut config = Config::default();
10510 config.provider = "openai".to_string();
10511 config.providers.openai = Some(OpenAIConfig {
10512 api_key: "test".to_string(),
10513 api_key_encrypted: None,
10514 credential_ref: None,
10515 base_url: None,
10516 model: Some("legacy-gpt-4o".to_string()),
10517 fast_model: None,
10518 vision_model: None,
10519 reasoning_effort: None,
10520 responses_only_models: vec![],
10521 request_overrides: None,
10522 extra: Default::default(),
10523 api_key_from_env: false,
10524 });
10525 config.features.provider_model_ref = true;
10526 config.defaults = Some(DefaultsConfig {
10527 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10528 fast: None,
10529 task_summary: None,
10530 vision: None,
10531 memory_background: None,
10532 planning: None,
10533 search: None,
10534 code_review: None,
10535 sub_agent: None,
10536 subagent_models: Default::default(),
10537 });
10538
10539 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10540 }
10541
10542 #[test]
10543 #[allow(clippy::field_reassign_with_default)]
10545 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10546 let mut config = Config::default();
10547 config.provider = "openai".to_string();
10548 config.providers.openai = Some(OpenAIConfig {
10549 api_key: "test".to_string(),
10550 api_key_encrypted: None,
10551 credential_ref: None,
10552 base_url: None,
10553 model: Some("legacy-gpt-4o".to_string()),
10554 fast_model: None,
10555 vision_model: None,
10556 reasoning_effort: None,
10557 responses_only_models: vec![],
10558 request_overrides: None,
10559 extra: Default::default(),
10560 api_key_from_env: false,
10561 });
10562 config.features.provider_model_ref = false;
10563 config.defaults = Some(DefaultsConfig {
10564 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10565 fast: None,
10566 task_summary: None,
10567 vision: None,
10568 memory_background: None,
10569 planning: None,
10570 search: None,
10571 code_review: None,
10572 sub_agent: None,
10573 subagent_models: Default::default(),
10574 });
10575
10576 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10577 }
10578
10579 #[test]
10580 #[allow(clippy::field_reassign_with_default)]
10582 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10583 let mut config = Config::default();
10584 config.provider = "openai".to_string();
10585 config.providers.openai = Some(OpenAIConfig {
10586 api_key: "test".to_string(),
10587 api_key_encrypted: None,
10588 credential_ref: None,
10589 base_url: None,
10590 model: Some("gpt-4o".to_string()),
10591 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10592 vision_model: None,
10593 reasoning_effort: None,
10594 responses_only_models: vec![],
10595 request_overrides: None,
10596 extra: Default::default(),
10597 api_key_from_env: false,
10598 });
10599 config.features.provider_model_ref = true;
10600 config.defaults = Some(DefaultsConfig {
10601 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10602 fast: Some(bamboo_domain::ProviderModelRef::new(
10603 "anthropic",
10604 "claude-3-5-haiku",
10605 )),
10606 task_summary: None,
10607 vision: None,
10608 memory_background: None,
10609 planning: None,
10610 search: None,
10611 code_review: None,
10612 sub_agent: None,
10613 subagent_models: Default::default(),
10614 });
10615
10616 assert_eq!(
10617 config.get_fast_model(),
10618 Some("claude-3-5-haiku".to_string())
10619 );
10620 }
10621
10622 #[test]
10623 #[allow(clippy::field_reassign_with_default)]
10625 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10626 let mut config = Config::default();
10627 config.provider = "openai".to_string();
10628 config.providers.openai = Some(OpenAIConfig {
10629 api_key: "test".to_string(),
10630 api_key_encrypted: None,
10631 credential_ref: None,
10632 base_url: None,
10633 model: Some("gpt-4o".to_string()),
10634 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10635 vision_model: None,
10636 reasoning_effort: None,
10637 responses_only_models: vec![],
10638 request_overrides: None,
10639 extra: Default::default(),
10640 api_key_from_env: false,
10641 });
10642 config.features.provider_model_ref = false;
10643 config.defaults = Some(DefaultsConfig {
10644 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10645 fast: Some(bamboo_domain::ProviderModelRef::new(
10646 "anthropic",
10647 "claude-3-5-haiku",
10648 )),
10649 task_summary: None,
10650 vision: None,
10651 memory_background: None,
10652 planning: None,
10653 search: None,
10654 code_review: None,
10655 sub_agent: None,
10656 subagent_models: Default::default(),
10657 });
10658
10659 assert_eq!(
10660 config.get_fast_model(),
10661 Some("legacy-gpt-4o-mini".to_string())
10662 );
10663 }
10664
10665 #[test]
10666 #[allow(clippy::field_reassign_with_default)]
10668 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10669 let mut config = Config::default();
10670 config.provider = "openai".to_string();
10671 config.features.provider_model_ref = true;
10672 config.defaults = Some(DefaultsConfig {
10673 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10674 fast: None,
10675 task_summary: None,
10676 vision: None,
10677 memory_background: None,
10678 planning: None,
10679 search: None,
10680 code_review: None,
10681 sub_agent: None,
10682 subagent_models: Default::default(),
10683 });
10684
10685 assert_eq!(
10686 config.get_fast_model(),
10687 Some("claude-3-7-sonnet".to_string())
10688 );
10689 }
10690
10691 #[test]
10692 #[allow(clippy::field_reassign_with_default)]
10694 fn get_memory_background_model_prefers_defaults_memory_background() {
10695 let mut config = Config::default();
10696 config.provider = "openai".to_string();
10697 config.providers.openai = Some(OpenAIConfig {
10698 api_key: "test".to_string(),
10699 api_key_encrypted: None,
10700 credential_ref: None,
10701 base_url: None,
10702 model: Some("gpt-4o".to_string()),
10703 fast_model: Some("gpt-4o-mini".to_string()),
10704 vision_model: None,
10705 reasoning_effort: None,
10706 responses_only_models: vec![],
10707 request_overrides: None,
10708 extra: Default::default(),
10709 api_key_from_env: false,
10710 });
10711 config.features.provider_model_ref = true;
10712 config.defaults = Some(DefaultsConfig {
10713 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10714 fast: Some(bamboo_domain::ProviderModelRef::new(
10715 "openai",
10716 "gpt-4o-mini",
10717 )),
10718 task_summary: None,
10719 vision: None,
10720 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10721 "anthropic",
10722 "claude-3-5-haiku",
10723 )),
10724 planning: None,
10725 search: None,
10726 code_review: None,
10727 sub_agent: None,
10728 subagent_models: Default::default(),
10729 });
10730
10731 assert_eq!(
10732 config.get_memory_background_model(),
10733 Some("claude-3-5-haiku".to_string())
10734 );
10735 }
10736
10737 #[test]
10738 #[allow(clippy::field_reassign_with_default)]
10740 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10741 let mut config = Config::default();
10742 config.provider = "openai".to_string();
10743 config.features.provider_model_ref = true;
10744 config.defaults = Some(DefaultsConfig {
10745 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10746 fast: Some(bamboo_domain::ProviderModelRef::new(
10747 "anthropic",
10748 "claude-3-5-haiku",
10749 )),
10750 task_summary: None,
10751 vision: None,
10752 memory_background: None,
10753 planning: None,
10754 search: None,
10755 code_review: None,
10756 sub_agent: None,
10757 subagent_models: Default::default(),
10758 });
10759
10760 assert_eq!(
10761 config.get_memory_background_model(),
10762 Some("claude-3-5-haiku".to_string())
10763 );
10764 }
10765
10766 #[test]
10767 #[allow(clippy::field_reassign_with_default)]
10769 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10770 let mut config = Config::default();
10771 config.provider = "openai".to_string();
10772 config.providers.openai = Some(OpenAIConfig {
10773 api_key: "test".to_string(),
10774 api_key_encrypted: None,
10775 credential_ref: None,
10776 base_url: None,
10777 model: Some("gpt-4o".to_string()),
10778 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10779 vision_model: None,
10780 reasoning_effort: None,
10781 responses_only_models: vec![],
10782 request_overrides: None,
10783 extra: Default::default(),
10784 api_key_from_env: false,
10785 });
10786 config.features.provider_model_ref = false;
10787 config.defaults = Some(DefaultsConfig {
10788 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10789 fast: Some(bamboo_domain::ProviderModelRef::new(
10790 "anthropic",
10791 "claude-3-5-haiku",
10792 )),
10793 task_summary: None,
10794 vision: None,
10795 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10796 "anthropic",
10797 "claude-3-5-haiku",
10798 )),
10799 planning: None,
10800 search: None,
10801 code_review: None,
10802 sub_agent: None,
10803 subagent_models: Default::default(),
10804 });
10805
10806 assert_eq!(
10807 config.get_memory_background_model(),
10808 Some("legacy-gpt-4o-mini".to_string())
10809 );
10810 }
10811
10812 #[test]
10819 fn is_host_trusted_requires_https_scheme() {
10820 let hosts = vec!["github.com/bigduu/".to_string()];
10821 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10822 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10823 }
10824
10825 #[test]
10826 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10827 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10829 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10830 let hosts = vec!["github.com/bigduu/".to_string()];
10832 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10833 }
10834
10835 #[test]
10836 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10837 let hosts = vec!["trusted.example.com".to_string()];
10838 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10839 assert!(!is_host_trusted(
10843 "https://trusted.example.com.evil.com/x",
10844 &hosts
10845 ));
10846 assert!(!is_host_trusted(
10847 "https://trusted.example.comevil.com/x",
10848 &hosts
10849 ));
10850 }
10851
10852 #[test]
10853 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10854 let hosts = vec!["github.com/bigduu/".to_string()];
10856 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10857 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10858 }
10859
10860 #[test]
10861 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10862 let hosts = vec!["example.com".to_string()];
10863 assert!(is_host_trusted("https://example.com/", &hosts));
10864 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10865 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10868 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10869 }
10870
10871 #[test]
10872 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10873 let hosts = vec!["github.com/bigduu/".to_string()];
10874 assert!(is_host_trusted(
10876 "https://someuser@github.com/bigduu/x",
10877 &hosts
10878 ));
10879 assert!(!is_host_trusted(
10882 "https://github.com@evil.com/bigduu/",
10883 &hosts
10884 ));
10885 }
10886
10887 #[test]
10888 fn is_host_trusted_ignores_an_explicit_port() {
10889 let hosts = vec!["github.com/bigduu/".to_string()];
10890 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10891 }
10892
10893 #[test]
10894 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10895 let hosts = vec!["github.com/bigduu/".to_string()];
10896 assert!(!is_host_trusted("not a url at all", &hosts));
10897 assert!(!is_host_trusted("", &hosts));
10898 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10900
10901 #[test]
10902 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10903 let hosts = vec!["github.com/bigduu/".to_string()];
10904 assert!(!is_host_trusted(
10907 "https://github.com/bigduu/../evil/x",
10908 &hosts
10909 ));
10910 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10912 }
10913
10914 #[test]
10915 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10916 let mut config = Config::default();
10917 config.plugin_trust.trusted_hosts = vec![
10918 " GitHub.com/BigDuu/ ".to_string(),
10919 "".to_string(),
10920 " ".to_string(),
10921 "Example.COM".to_string(),
10922 ];
10923 config.normalize_plugin_trust_settings();
10924 assert_eq!(
10925 config.plugin_trust.trusted_hosts,
10926 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10927 );
10928 }
10929
10930 #[test]
10936 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10937 let config = Config::default();
10939 assert_eq!(
10940 config.plugin_trust.enforcement,
10941 PluginTrustEnforcement::Strict
10942 );
10943 assert!(!config.plugin_trust.enforcement_is_off());
10944
10945 let json = serde_json::json!({
10951 "trusted_hosts": ["example.com"],
10952 "trusted_keys": [],
10953 });
10954 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10955 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10956 }
10957
10958 #[test]
10959 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10960 for raw in ["off", "OFF", "Off", " off "] {
10961 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10962 "enforcement": raw,
10963 }))
10964 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10965 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10966 assert!(trust.enforcement_is_off());
10967 }
10968 for raw in ["strict", "STRICT", " Strict "] {
10969 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10970 "enforcement": raw,
10971 }))
10972 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10973 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10974 }
10975
10976 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10977 "enforcement": "nonsense",
10978 }))
10979 .expect_err("an unrecognized string must be rejected, not silently default");
10980 assert!(err.to_string().contains("nonsense"));
10981 }
10982
10983 #[test]
10984 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10985 let trust: PluginTrustConfig =
10988 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10989 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10990
10991 let trust: PluginTrustConfig =
10992 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10993 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10994 }
10995
10996 #[test]
10997 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10998 let trust = PluginTrustConfig {
11003 enforcement: PluginTrustEnforcement::Off,
11004 ..PluginTrustConfig::default()
11005 };
11006 let json = serde_json::to_value(&trust).unwrap();
11007 assert_eq!(json["enforcement"], "off");
11008
11009 let trust = PluginTrustConfig {
11010 enforcement: PluginTrustEnforcement::Strict,
11011 ..PluginTrustConfig::default()
11012 };
11013 let json = serde_json::to_value(&trust).unwrap();
11014 assert_eq!(json["enforcement"], "strict");
11015 }
11016
11017 #[test]
11018 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
11019 let mut config = Config::default();
11022 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
11023 config.normalize_plugin_trust_settings();
11024 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
11025 }
11026
11027 #[test]
11028 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11029 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11034 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11035 )));
11036 assert_eq!(
11037 config.plugin_trust.enforcement,
11038 PluginTrustEnforcement::Strict
11039 );
11040
11041 let outcome = crate::dot_path::apply_dot_path_set(
11042 &config,
11043 "plugin_trust.enforcement",
11044 crate::dot_path::parse_cli_value("off"),
11045 )
11046 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11047 assert_eq!(
11048 outcome.config.plugin_trust.enforcement,
11049 PluginTrustEnforcement::Off
11050 );
11051
11052 let outcome = crate::dot_path::apply_dot_path_set(
11054 &outcome.config,
11055 "plugin_trust.enforcement",
11056 crate::dot_path::parse_cli_value("strict"),
11057 )
11058 .expect("setting it back to strict should also round-trip");
11059 assert_eq!(
11060 outcome.config.plugin_trust.enforcement,
11061 PluginTrustEnforcement::Strict
11062 );
11063 }
11064}
11065
11066#[cfg(test)]
11067mod final_evidence_feature_tests {
11068 use super::FeatureFlags;
11069
11070 #[test]
11071 fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11072 let existing: FeatureFlags =
11073 serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11074 assert!(!existing.final_evidence_check);
11075 let enabled: FeatureFlags =
11076 serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11077 let restored: FeatureFlags =
11078 serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11079 assert!(restored.final_evidence_check);
11080 assert!(!FeatureFlags::default().final_evidence_check);
11081 }
11082}