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(default, skip_serializing_if = "Option::is_none")]
2698 pub work_review: Option<bamboo_domain::ProviderModelRef>,
2699 #[serde(
2702 default,
2703 skip_serializing_if = "Option::is_none",
2704 alias = "sub_session"
2705 )]
2706 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2707 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2711 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2712}
2713
2714#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2716pub struct HooksConfig {
2717 #[serde(default)]
2719 pub image_fallback: ImageFallbackHookConfig,
2720}
2721
2722pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2724pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2726pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2730
2731pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2733 "SessionStart",
2734 "UserPromptSubmit",
2735 "PreToolUse",
2736 "PostToolUse",
2737 "Stop",
2738 "SessionEnd",
2739 "PreCompact",
2740 "Notification",
2741 "BambooModelCallUsage",
2742];
2743
2744fn default_lifecycle_hook_timeout_ms() -> u64 {
2745 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2746}
2747
2748fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2749 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2750}
2751
2752fn lifecycle_hook_enabled_default() -> bool {
2753 true
2754}
2755
2756fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2757 *value
2758}
2759
2760#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2765pub struct LifecycleHooksConfig {
2766 #[serde(default)]
2767 pub enabled: bool,
2768 #[serde(
2769 default,
2770 rename = "SessionStart",
2771 skip_serializing_if = "Vec::is_empty"
2772 )]
2773 pub session_start: Vec<LifecycleHookGroup>,
2774 #[serde(
2775 default,
2776 rename = "UserPromptSubmit",
2777 skip_serializing_if = "Vec::is_empty"
2778 )]
2779 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2780 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2781 pub pre_tool_use: Vec<LifecycleHookGroup>,
2782 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2783 pub post_tool_use: Vec<LifecycleHookGroup>,
2784 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2785 pub stop: Vec<LifecycleHookGroup>,
2786 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2787 pub session_end: Vec<LifecycleHookGroup>,
2788 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2789 pub pre_compact: Vec<LifecycleHookGroup>,
2790 #[serde(
2791 default,
2792 rename = "Notification",
2793 skip_serializing_if = "Vec::is_empty"
2794 )]
2795 pub notification: Vec<LifecycleHookGroup>,
2796 #[serde(
2797 default,
2798 rename = "BambooModelCallUsage",
2799 skip_serializing_if = "Vec::is_empty"
2800 )]
2801 pub model_call_usage: Vec<LifecycleHookGroup>,
2802}
2803
2804impl LifecycleHooksConfig {
2805 pub fn is_empty(&self) -> bool {
2806 !self.enabled
2807 && self.session_start.is_empty()
2808 && self.user_prompt_submit.is_empty()
2809 && self.pre_tool_use.is_empty()
2810 && self.post_tool_use.is_empty()
2811 && self.stop.is_empty()
2812 && self.session_end.is_empty()
2813 && self.pre_compact.is_empty()
2814 && self.notification.is_empty()
2815 && self.model_call_usage.is_empty()
2816 }
2817}
2818
2819#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2821pub struct LifecycleHookGroup {
2822 #[serde(
2825 default = "lifecycle_hook_enabled_default",
2826 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2827 )]
2828 pub enabled: bool,
2829 #[serde(default, skip_serializing_if = "Option::is_none")]
2830 pub matcher: Option<String>,
2831 #[serde(default)]
2832 pub hooks: Vec<LifecycleHookHandler>,
2833}
2834
2835impl Default for LifecycleHookGroup {
2836 fn default() -> Self {
2837 Self {
2838 enabled: true,
2839 matcher: None,
2840 hooks: Vec::new(),
2841 }
2842 }
2843}
2844
2845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2850#[serde(tag = "type", rename_all = "lowercase")]
2851pub enum LifecycleHookHandler {
2852 Command {
2853 command: String,
2854 #[serde(
2855 default = "default_lifecycle_hook_timeout_ms",
2856 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2857 )]
2858 timeout_ms: u64,
2859 },
2860 Script {
2861 path: String,
2862 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2863 runner: LifecycleScriptRunner,
2864 #[serde(
2865 default = "default_lifecycle_hook_timeout_ms",
2866 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2867 )]
2868 timeout_ms: u64,
2869 },
2870}
2871
2872#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2878#[serde(rename_all = "lowercase")]
2879pub enum LifecycleScriptRunner {
2880 #[default]
2881 Auto,
2882 Node,
2883 Bun,
2884 Python,
2885 Bash,
2886 PowerShell,
2887 Cmd,
2888}
2889
2890impl LifecycleScriptRunner {
2891 pub fn is_auto(&self) -> bool {
2892 matches!(self, Self::Auto)
2893 }
2894
2895 pub fn as_str(self) -> &'static str {
2896 match self {
2897 Self::Auto => "auto",
2898 Self::Node => "node",
2899 Self::Bun => "bun",
2900 Self::Python => "python",
2901 Self::Bash => "bash",
2902 Self::PowerShell => "powershell",
2903 Self::Cmd => "cmd",
2904 }
2905 }
2906
2907 pub fn supports_path(self, path: &str) -> bool {
2909 let extension = lifecycle_script_extension(path);
2910 match self {
2911 Self::Auto => extension.is_some(),
2912 Self::Node | Self::Bun => {
2913 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2914 }
2915 Self::Python => matches!(extension.as_deref(), Some("py")),
2916 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2917 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2918 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2919 }
2920 }
2921}
2922
2923pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2926 let extension = std::path::Path::new(path)
2927 .extension()?
2928 .to_str()?
2929 .to_ascii_lowercase();
2930 matches!(
2931 extension.as_str(),
2932 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2933 )
2934 .then_some(extension)
2935}
2936
2937impl LifecycleHookHandler {
2938 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2939 Self::Command {
2940 command: command.into(),
2941 timeout_ms,
2942 }
2943 }
2944
2945 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2946 Self::Script {
2947 path: path.into(),
2948 runner,
2949 timeout_ms,
2950 }
2951 }
2952
2953 pub fn timeout_ms(&self) -> u64 {
2954 match self {
2955 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2956 }
2957 }
2958}
2959
2960#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2967pub struct RequestOverridesConfig {
2968 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2970 pub common: RequestScopeOverride,
2971 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2973 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2974 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2976 pub rules: Vec<ModelRequestRule>,
2977}
2978
2979#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2981pub struct ModelRequestRule {
2982 pub model_pattern: String,
2984 #[serde(default, skip_serializing_if = "Option::is_none")]
2986 pub endpoint: Option<String>,
2987 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2989 pub scope: RequestScopeOverride,
2990}
2991
2992#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2994pub struct RequestScopeOverride {
2995 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2997 pub headers: BTreeMap<String, TemplateExpr>,
2998 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3000 pub body_patch: Vec<BodyPatch>,
3001}
3002
3003impl RequestScopeOverride {
3004 pub fn is_empty(&self) -> bool {
3005 self.headers.is_empty() && self.body_patch.is_empty()
3006 }
3007}
3008
3009#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3011pub struct BodyPatch {
3012 pub path: String,
3014 #[serde(default)]
3016 pub op: BodyPatchOp,
3017 #[serde(default, skip_serializing_if = "Option::is_none")]
3019 pub value: Option<PatchValue>,
3020}
3021
3022#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3024#[serde(rename_all = "snake_case")]
3025pub enum BodyPatchOp {
3026 #[default]
3027 Set,
3028 Remove,
3029}
3030
3031#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3033#[serde(untagged)]
3034pub enum PatchValue {
3035 Template(TemplateExpr),
3036 Json(Value),
3037}
3038
3039#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3041#[serde(untagged)]
3042pub enum TemplateExpr {
3043 Literal(String),
3045 Structured(TemplateExprSpec),
3047}
3048
3049#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3051#[serde(tag = "type", rename_all = "snake_case")]
3052pub enum TemplateExprSpec {
3053 Literal { value: String },
3055 EnvRef {
3057 name: String,
3058 #[serde(default, skip_serializing_if = "Option::is_none")]
3059 fallback: Option<String>,
3060 },
3061 Generated { generator: GeneratedValue },
3063 Format { template: String },
3065}
3066
3067#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3069#[serde(rename_all = "snake_case")]
3070pub enum GeneratedValue {
3071 Uuid,
3072 UnixMs,
3073}
3074
3075#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3077pub struct ToolsConfig {
3078 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3080 pub disabled: Vec<String>,
3081
3082 #[serde(default, flatten)]
3084 pub extra: BTreeMap<String, Value>,
3085}
3086
3087impl ToolsConfig {
3088 fn is_empty(&self) -> bool {
3089 self.disabled.is_empty() && self.extra.is_empty()
3090 }
3091}
3092
3093#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3095pub struct SkillsConfig {
3096 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3098 pub disabled: Vec<String>,
3099
3100 #[serde(default, flatten)]
3102 pub extra: BTreeMap<String, Value>,
3103}
3104
3105impl SkillsConfig {
3106 fn is_empty(&self) -> bool {
3107 self.disabled.is_empty() && self.extra.is_empty()
3108 }
3109}
3110
3111#[derive(Debug, Clone, Serialize, Deserialize)]
3116pub struct ImageFallbackHookConfig {
3117 #[serde(default = "default_true_hooks")]
3118 pub enabled: bool,
3119
3120 #[serde(default = "default_image_fallback_mode")]
3122 pub mode: String,
3123}
3124
3125impl Default for ImageFallbackHookConfig {
3126 fn default() -> Self {
3127 Self {
3128 enabled: default_true_hooks(),
3129 mode: default_image_fallback_mode(),
3130 }
3131 }
3132}
3133
3134fn default_image_fallback_mode() -> String {
3135 "placeholder".to_string()
3136}
3137
3138fn default_true_hooks() -> bool {
3139 false
3142}
3143
3144pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3156
3157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3158pub struct OpenAIConfig {
3159 #[serde(default, skip_serializing)]
3163 pub api_key: String,
3164 #[serde(default, skip_serializing_if = "Option::is_none")]
3166 pub api_key_encrypted: Option<String>,
3167 #[serde(default, skip_serializing_if = "Option::is_none")]
3169 pub credential_ref: Option<crate::CredentialRef>,
3170 #[serde(skip)]
3175 pub api_key_from_env: bool,
3176 #[serde(skip_serializing_if = "Option::is_none")]
3178 pub base_url: Option<String>,
3179 #[serde(skip_serializing_if = "Option::is_none")]
3181 pub model: Option<String>,
3182 #[serde(default, skip_serializing_if = "Option::is_none")]
3185 pub fast_model: Option<String>,
3186 #[serde(default, skip_serializing_if = "Option::is_none")]
3189 pub vision_model: Option<String>,
3190 #[serde(skip_serializing_if = "Option::is_none")]
3192 pub reasoning_effort: Option<ReasoningEffort>,
3193
3194 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3201 pub responses_only_models: Vec<String>,
3202 #[serde(default, skip_serializing_if = "Option::is_none")]
3204 pub request_overrides: Option<RequestOverridesConfig>,
3205
3206 #[serde(default, flatten)]
3208 pub extra: BTreeMap<String, Value>,
3209}
3210
3211impl OpenAIConfig {
3212 pub fn explicit_prompt_cache_enabled(&self) -> bool {
3219 self.extra
3220 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3221 .and_then(Value::as_bool)
3222 .unwrap_or(true)
3223 }
3224}
3225
3226#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3238pub struct AnthropicConfig {
3239 #[serde(default, skip_serializing)]
3243 pub api_key: String,
3244 #[serde(default, skip_serializing_if = "Option::is_none")]
3246 pub api_key_encrypted: Option<String>,
3247 #[serde(default, skip_serializing_if = "Option::is_none")]
3249 pub credential_ref: Option<crate::CredentialRef>,
3250 #[serde(skip)]
3255 pub api_key_from_env: bool,
3256 #[serde(skip_serializing_if = "Option::is_none")]
3258 pub base_url: Option<String>,
3259 #[serde(skip_serializing_if = "Option::is_none")]
3261 pub model: Option<String>,
3262 #[serde(default, skip_serializing_if = "Option::is_none")]
3265 pub fast_model: Option<String>,
3266 #[serde(default, skip_serializing_if = "Option::is_none")]
3269 pub vision_model: Option<String>,
3270 #[serde(skip_serializing_if = "Option::is_none")]
3272 pub max_tokens: Option<u32>,
3273 #[serde(skip_serializing_if = "Option::is_none")]
3275 pub reasoning_effort: Option<ReasoningEffort>,
3276 #[serde(default, skip_serializing_if = "Option::is_none")]
3278 pub request_overrides: Option<RequestOverridesConfig>,
3279
3280 #[serde(default, skip_serializing_if = "Option::is_none")]
3297 pub thinking_replay_always: Option<bool>,
3298
3299 #[serde(default, flatten)]
3301 pub extra: BTreeMap<String, Value>,
3302}
3303
3304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3315pub struct GeminiConfig {
3316 #[serde(default, skip_serializing)]
3320 pub api_key: String,
3321 #[serde(default, skip_serializing_if = "Option::is_none")]
3323 pub api_key_encrypted: Option<String>,
3324 #[serde(default, skip_serializing_if = "Option::is_none")]
3326 pub credential_ref: Option<crate::CredentialRef>,
3327 #[serde(skip)]
3332 pub api_key_from_env: bool,
3333 #[serde(skip_serializing_if = "Option::is_none")]
3335 pub base_url: Option<String>,
3336 #[serde(skip_serializing_if = "Option::is_none")]
3338 pub model: Option<String>,
3339 #[serde(default, skip_serializing_if = "Option::is_none")]
3342 pub fast_model: Option<String>,
3343 #[serde(default, skip_serializing_if = "Option::is_none")]
3346 pub vision_model: Option<String>,
3347 #[serde(skip_serializing_if = "Option::is_none")]
3349 pub reasoning_effort: Option<ReasoningEffort>,
3350 #[serde(default, skip_serializing_if = "Option::is_none")]
3352 pub request_overrides: Option<RequestOverridesConfig>,
3353
3354 #[serde(default, flatten)]
3356 pub extra: BTreeMap<String, Value>,
3357}
3358
3359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3371pub struct CopilotConfig {
3372 #[serde(default)]
3374 pub enabled: bool,
3375 #[serde(default)]
3377 pub headless_auth: bool,
3378 #[serde(skip_serializing_if = "Option::is_none")]
3380 pub model: Option<String>,
3381 #[serde(default, skip_serializing_if = "Option::is_none")]
3384 pub fast_model: Option<String>,
3385 #[serde(default, skip_serializing_if = "Option::is_none")]
3388 pub vision_model: Option<String>,
3389 #[serde(skip_serializing_if = "Option::is_none")]
3391 pub reasoning_effort: Option<ReasoningEffort>,
3392
3393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3402 pub responses_only_models: Vec<String>,
3403 #[serde(default, skip_serializing_if = "Option::is_none")]
3405 pub request_overrides: Option<RequestOverridesConfig>,
3406
3407 #[serde(default, flatten)]
3409 pub extra: BTreeMap<String, Value>,
3410}
3411
3412#[derive(Debug, Clone, Serialize, Deserialize)]
3417pub struct BodhiConfig {
3418 #[serde(default, skip_serializing)]
3420 pub api_key: String,
3421 #[serde(default, skip_serializing_if = "Option::is_none")]
3423 pub api_key_encrypted: Option<String>,
3424 #[serde(default, skip_serializing_if = "Option::is_none")]
3426 pub credential_ref: Option<crate::CredentialRef>,
3427 #[serde(skip_serializing_if = "Option::is_none")]
3429 pub base_url: Option<String>,
3430 #[serde(skip_serializing_if = "Option::is_none")]
3432 pub target_provider: Option<String>,
3433 #[serde(skip_serializing_if = "Option::is_none")]
3435 pub reasoning_effort: Option<ReasoningEffort>,
3436
3437 #[serde(default, flatten)]
3439 pub extra: BTreeMap<String, Value>,
3440}
3441
3442fn default_provider() -> String {
3444 "anthropic".to_string()
3445}
3446
3447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3479pub struct ProviderInstanceConfig {
3480 pub provider_type: String,
3485
3486 #[serde(default, skip_serializing_if = "Option::is_none")]
3488 pub label: Option<String>,
3489
3490 #[serde(default, skip_serializing)]
3492 pub api_key: String,
3493
3494 #[serde(default, skip_serializing_if = "Option::is_none")]
3497 pub api_key_encrypted: Option<String>,
3498
3499 #[serde(default, skip_serializing_if = "Option::is_none")]
3501 pub credential_ref: Option<crate::CredentialRef>,
3502
3503 #[serde(default, skip_serializing_if = "Option::is_none")]
3505 pub base_url: Option<String>,
3506
3507 #[serde(default, skip_serializing_if = "Option::is_none")]
3509 pub model: Option<String>,
3510
3511 #[serde(default, skip_serializing_if = "Option::is_none")]
3513 pub fast_model: Option<String>,
3514
3515 #[serde(default, skip_serializing_if = "Option::is_none")]
3517 pub vision_model: Option<String>,
3518
3519 #[serde(default, skip_serializing_if = "Option::is_none")]
3521 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3522
3523 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3525 pub responses_only_models: Vec<String>,
3526
3527 #[serde(default, skip_serializing_if = "Option::is_none")]
3529 pub request_overrides: Option<RequestOverridesConfig>,
3530
3531 #[serde(default = "default_true")]
3534 pub enabled: bool,
3535
3536 #[serde(default, flatten)]
3538 pub extra: BTreeMap<String, Value>,
3539}
3540
3541fn default_true() -> bool {
3542 true
3543}
3544
3545fn default_port() -> u16 {
3547 9562
3548}
3549
3550fn default_bind() -> String {
3552 "127.0.0.1".to_string()
3553}
3554
3555fn default_workers() -> usize {
3557 10
3558}
3559
3560fn default_data_dir() -> PathBuf {
3562 super::paths::bamboo_dir()
3563}
3564
3565#[derive(Debug, Clone, Serialize, Deserialize)]
3567pub struct ServerConfig {
3568 #[serde(default = "default_port")]
3570 pub port: u16,
3571
3572 #[serde(default = "default_bind")]
3574 pub bind: String,
3575
3576 pub static_dir: Option<PathBuf>,
3578
3579 #[serde(default = "default_workers")]
3581 pub workers: usize,
3582
3583 #[serde(default, skip_serializing_if = "Option::is_none")]
3591 pub tls: Option<TlsConfig>,
3592
3593 #[serde(default, flatten)]
3595 pub extra: BTreeMap<String, Value>,
3596}
3597
3598impl Default for ServerConfig {
3599 fn default() -> Self {
3600 Self {
3601 port: default_port(),
3602 bind: default_bind(),
3603 static_dir: None,
3604 workers: default_workers(),
3605 tls: None,
3606 extra: BTreeMap::new(),
3607 }
3608 }
3609}
3610
3611#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3617pub struct TlsConfig {
3618 pub cert_file: PathBuf,
3620 pub key_file: PathBuf,
3622}
3623
3624#[derive(Debug, Clone, Serialize, Deserialize)]
3626pub struct ProxyAuth {
3627 pub username: String,
3629 pub password: String,
3631}
3632
3633fn parse_bool_env(value: &str) -> bool {
3637 matches!(
3638 value.trim().to_ascii_lowercase().as_str(),
3639 "1" | "true" | "yes" | "y" | "on"
3640 )
3641}
3642
3643fn expand_user_path(value: &str) -> PathBuf {
3644 let trimmed = value.trim();
3645 if let Some(rest) = trimmed.strip_prefix("~/") {
3646 if let Some(home) = dirs::home_dir() {
3647 return home.join(rest);
3648 }
3649 }
3650 PathBuf::from(trimmed)
3651}
3652
3653impl Default for Config {
3654 fn default() -> Self {
3655 Self::create_default()
3664 }
3665}
3666
3667#[derive(Debug, Clone, PartialEq, Eq)]
3669pub struct PromptSafeEnvVarEntry {
3670 pub name: String,
3671 pub secret: bool,
3672 pub description: Option<String>,
3673}
3674
3675static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3679
3680static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3681
3682fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3683 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3684}
3685
3686fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3687 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3688}
3689
3690impl Config {
3691 fn from_parts(
3692 values: ConfigValues,
3693 memory: Option<MemoryConfig>,
3694 subagents: SubagentsConfig,
3695 providers: ProviderConfigs,
3696 ) -> Self {
3697 Self {
3698 values,
3699 memory: crate::MemoryConfigModule(memory),
3700 subagents: crate::SubagentsConfigModule(subagents),
3701 providers: crate::ProviderConfigsModule(providers),
3702 recovery_status: None,
3703 }
3704 }
3705
3706 pub(crate) fn section_values(&self) -> ConfigValues {
3712 self.values.clone()
3713 }
3714
3715 pub(crate) fn from_section_parts(
3716 values: ConfigValues,
3717 memory: Option<MemoryConfig>,
3718 subagents: SubagentsConfig,
3719 providers: ProviderConfigs,
3720 ) -> Self {
3721 Self::from_parts(values, memory, subagents, providers)
3722 }
3723
3724 pub fn memory(&self) -> &Option<MemoryConfig> {
3726 &self.memory.0
3727 }
3728
3729 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3730 &mut self.memory.0
3731 }
3732
3733 pub fn subagents(&self) -> &SubagentsConfig {
3735 &self.subagents.0
3736 }
3737
3738 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3739 &mut self.subagents.0
3740 }
3741
3742 pub fn providers(&self) -> &ProviderConfigs {
3744 &self.providers.0
3745 }
3746
3747 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3748 &mut self.providers.0
3749 }
3750
3751 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3755 if self
3756 .default_provider_instance
3757 .as_ref()
3758 .is_some_and(|id| self.provider_instances.contains_key(id))
3759 {
3760 self.providers.0.clear_legacy_builtin_aliases();
3761 }
3762 }
3763
3764 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3770 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3771 let object = value
3772 .as_object_mut()
3773 .expect("ConfigRoot always serializes as a JSON object");
3774 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3775 object.insert(
3776 "subagents".to_string(),
3777 serde_json::to_value(self.subagents())?,
3778 );
3779 object.insert(
3780 "providers".to_string(),
3781 serde_json::to_value(self.providers())?,
3782 );
3783 Ok(value)
3784 }
3785
3786 pub fn new() -> Self {
3805 Self::from_data_dir(None)
3806 }
3807
3808 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3809 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3810 Ok(facade) => facade.effective_config(),
3811 Err(error) => {
3812 tracing::warn!(
3813 error = %error,
3814 "completed modular configuration is unavailable; refusing legacy-root fallback"
3815 );
3816 Self::create_default()
3817 }
3818 };
3819
3820 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3821 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3822 config.proxy_auth = None;
3823 }
3824 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3825 tracing::warn!(error = %error, "provider credential hydration unavailable");
3826 }
3827 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3828 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3829 }
3830 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3831 tracing::warn!(error = %error, "env credential hydration unavailable");
3832 for entry in &mut config.env_vars {
3833 if entry.secret {
3834 entry.value.clear();
3835 }
3836 }
3837 }
3838 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3839 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3840 config.clear_cluster_runtime_credentials();
3841 }
3842 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3843 tracing::warn!(error = %error, "notification credential hydration unavailable");
3844 config.notifications.ntfy.token = None;
3845 config.notifications.bark.device_key = None;
3846 }
3847 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3848 tracing::warn!(error = %error, "connect credential hydration unavailable");
3849 for platform in &mut config.connect.platforms {
3850 platform.token = None;
3851 platform.app_secret = None;
3852 }
3853 }
3854 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3855 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3856 config.clear_access_control_runtime_verifiers();
3857 }
3858 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3859 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3860 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3861 broker.token.clear();
3862 }
3863 }
3864 config.normalize_tool_settings();
3865 config.normalize_skill_settings();
3866 config.normalize_plugin_trust_settings();
3867 config.extra.remove("data_dir");
3868 if apply_env {
3869 config.apply_env_overrides();
3870 }
3871 if publish {
3872 config.publish_env_vars();
3873 }
3874 config
3875 }
3876
3877 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3886 let data_dir = data_dir
3888 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3889 .unwrap_or_else(default_data_dir);
3890
3891 match crate::modular_authority_boundary_present(&data_dir) {
3892 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3893 Ok(false) => {}
3894 Err(error) => {
3895 tracing::warn!(
3896 error = %error,
3897 "modular configuration marker is unavailable; refusing legacy-root fallback"
3898 );
3899 let mut config = Self::create_default();
3900 if apply_env {
3901 config.apply_env_overrides();
3902 }
3903 if publish {
3904 config.publish_env_vars();
3905 }
3906 return config;
3907 }
3908 }
3909
3910 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3915 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3916 .map_err(|error| {
3917 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3918 error
3919 })
3920 .is_ok();
3921 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3922 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3923 .map_err(|error| {
3924 tracing::warn!(error = %error, "cluster credential migration unavailable");
3925 error
3926 })
3927 .is_ok();
3928
3929 match crate::modular_authority_boundary_present(&data_dir) {
3934 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3935 Ok(false) => {}
3936 Err(error) => {
3937 tracing::warn!(
3938 error = %error,
3939 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3940 );
3941 let mut config = Self::create_default();
3942 if apply_env {
3943 config.apply_env_overrides();
3944 }
3945 if publish {
3946 config.publish_env_vars();
3947 }
3948 return config;
3949 }
3950 }
3951
3952 let config_path = data_dir.join("config.json");
3953
3954 let mut config = if config_path.exists() {
3955 if let Ok(content) = std::fs::read_to_string(&config_path) {
3956 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3957 tracing::warn!(
3967 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3968 e
3969 );
3970 let quarantine_path = quarantine_corrupt_config(&config_path);
3971 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3972 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3973 .or_else(|| {
3974 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3975 (cfg, ConfigRecoverySource::Backup { generation })
3976 })
3977 })
3978 .unwrap_or_else(|| {
3979 tracing::warn!(
3980 "Could not salvage and no usable config.json.bak; using defaults"
3981 );
3982 (Self::create_default(), ConfigRecoverySource::Defaults)
3983 });
3984 recovered.recovery_status = Some(ConfigRecoveryStatus {
3985 source,
3986 quarantine_path,
3987 confirmed: false,
3988 });
3989 recovered
3990 })
3991 } else {
3992 Self::create_default()
3993 }
3994 } else {
3995 Self::create_default()
3996 };
3997
3998 let mut memory_module = config.memory.clone();
4004 match memory_module.load_sync(&data_dir) {
4005 Ok(true) => config.memory = memory_module,
4006 Ok(false) => {}
4007 Err(error) => tracing::warn!(
4008 "Failed to load memory.json; using legacy config.json memory: {error}"
4009 ),
4010 }
4011 let mut subagents_module = config.subagents.clone();
4012 match subagents_module.load_sync(&data_dir) {
4013 Ok(true) => config.subagents = subagents_module,
4014 Ok(false) => {}
4015 Err(error) => tracing::warn!(
4016 "Failed to load subagents.json; using legacy config.json subagents: {error}"
4017 ),
4018 }
4019 if provider_mcp_ready {
4020 let mut providers_module = config.providers.clone();
4021 match providers_module.load_sync(&data_dir) {
4022 Ok(true) => config.providers = providers_module,
4023 Ok(false) => {}
4024 Err(error) => tracing::warn!(
4025 "Failed to load providers.json; using legacy config.json providers: {error}"
4026 ),
4027 }
4028 }
4029
4030 config.hydrate_proxy_auth_from_encrypted();
4032 if provider_mcp_ready {
4033 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4034 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4035 config.proxy_auth = None;
4036 }
4037 }
4038 config.hydrate_provider_api_keys_from_encrypted();
4040 config.hydrate_provider_instance_api_keys_from_encrypted();
4042 config.hydrate_mcp_secrets_from_encrypted();
4044 if provider_mcp_ready {
4045 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4046 tracing::warn!(error = %error, "provider credential hydration unavailable");
4047 }
4048 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4049 tracing::warn!(error = %error, "MCP credential hydration unavailable");
4050 }
4051 }
4052 config.hydrate_env_vars_from_encrypted();
4054 if provider_mcp_ready {
4055 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4056 tracing::warn!(error = %error, "env credential hydration unavailable");
4057 for entry in &mut config.env_vars {
4058 if entry.secret {
4059 entry.value.clear();
4060 }
4061 }
4062 }
4063 }
4064 if cluster_ready {
4069 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4070 tracing::warn!(error = %error, "cluster credential hydration unavailable");
4071 config.clear_cluster_runtime_credentials();
4072 }
4073 } else {
4074 config.clear_cluster_runtime_credentials();
4075 }
4076 config.hydrate_broker_token_from_encrypted();
4078 config.hydrate_notifications_from_encrypted();
4080 if provider_mcp_ready {
4081 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4082 tracing::warn!(error = %error, "notification credential hydration unavailable");
4083 config.notifications.ntfy.token = None;
4084 config.notifications.bark.device_key = None;
4085 }
4086 } else {
4087 config.notifications.ntfy.token = None;
4092 config.notifications.bark.device_key = None;
4093 }
4094 config.merge_connect_config(&data_dir);
4099 scrub_legacy_connect_from_config_backups(&data_dir);
4109 config.hydrate_connect_platform_tokens_from_encrypted();
4111 if provider_mcp_ready {
4112 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4113 tracing::warn!(error = %error, "connect credential hydration unavailable");
4114 for platform in &mut config.connect.platforms {
4115 platform.token = None;
4116 platform.app_secret = None;
4117 }
4118 }
4119 }
4120 if provider_mcp_ready {
4121 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4122 tracing::warn!(error = %error, "access-control credential hydration unavailable");
4123 config.clear_access_control_runtime_verifiers();
4124 }
4125 } else {
4126 config.clear_access_control_runtime_verifiers();
4127 }
4128 config.normalize_tool_settings();
4129 config.normalize_skill_settings();
4130 config.normalize_plugin_trust_settings();
4131
4132 config.extra.remove("data_dir");
4135
4136 if apply_env {
4140 config.apply_env_overrides();
4141 }
4142
4143 if publish {
4147 config.publish_env_vars();
4148 }
4149
4150 config
4151 }
4152
4153 fn apply_env_overrides(&mut self) {
4157 if let Ok(port) = std::env::var("BAMBOO_PORT") {
4158 if let Ok(port) = port.parse() {
4159 self.server.port = port;
4160 }
4161 }
4162
4163 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4164 self.server.bind = bind;
4165 }
4166
4167 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4172 let provider = provider.trim().to_string();
4173 if !provider.is_empty() {
4174 self.provider = provider.clone();
4175 if !self.provider_instances.is_empty() {
4176 let selected = self
4177 .provider_instances
4178 .contains_key(&provider)
4179 .then_some(provider.clone())
4180 .or_else(|| {
4181 let mut matching = self
4182 .provider_instances
4183 .iter()
4184 .filter(|(_, instance)| {
4185 instance.enabled && instance.provider_type == provider
4186 })
4187 .map(|(id, _)| id.clone())
4188 .collect::<Vec<_>>();
4189 matching.sort();
4190 matching.into_iter().next()
4191 });
4192 self.default_provider_instance = selected;
4193 }
4194 }
4195 }
4196
4197 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4198 self.headless_auth = parse_bool_env(&headless);
4199 }
4200
4201 if let Ok(project_prompt_injection) =
4202 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4203 {
4204 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4205 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4206 }
4207
4208 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4209 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4210 memory.relevant_recall = parse_bool_env(&relevant_recall);
4211 }
4212
4213 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4214 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4215 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4216 }
4217
4218 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4219 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4220 memory.project_first_dream = parse_bool_env(&project_first_dream);
4221 }
4222
4223 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4230 let key = key.trim();
4231 if !key.is_empty() {
4232 if self.provider_instances.is_empty() {
4233 let openai = self
4234 .providers
4235 .openai
4236 .get_or_insert_with(OpenAIConfig::default);
4237 openai.api_key = key.to_string();
4238 openai.api_key_from_env = true;
4239 } else {
4240 self.apply_provider_instance_env_key("openai", key);
4241 }
4242 }
4243 }
4244 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4245 let key = key.trim();
4246 if !key.is_empty() {
4247 if self.provider_instances.is_empty() {
4248 let anthropic = self
4249 .providers
4250 .anthropic
4251 .get_or_insert_with(AnthropicConfig::default);
4252 anthropic.api_key = key.to_string();
4253 anthropic.api_key_from_env = true;
4254 } else {
4255 self.apply_provider_instance_env_key("anthropic", key);
4256 }
4257 }
4258 }
4259 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4260 let key = key.trim();
4261 if !key.is_empty() {
4262 if self.provider_instances.is_empty() {
4263 let gemini = self
4264 .providers
4265 .gemini
4266 .get_or_insert_with(GeminiConfig::default);
4267 gemini.api_key = key.to_string();
4268 gemini.api_key_from_env = true;
4269 } else {
4270 self.apply_provider_instance_env_key("gemini", key);
4271 }
4272 }
4273 }
4274 }
4275
4276 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4277 for instance in self.provider_instances.values_mut().filter(|instance| {
4278 instance.provider_type == provider_type
4279 && crate::provider_instance_api_key_from_env(instance)
4280 }) {
4281 instance.api_key = key.to_string();
4282 instance.api_key_encrypted = None;
4283 }
4284 }
4285
4286 pub fn apply_runtime_env_overrides(&mut self) {
4291 self.apply_env_overrides();
4292 }
4293
4294 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4300 Self::from_data_dir_impl(data_dir, true, true)
4301 }
4302
4303 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4307 Self::from_data_dir_impl(data_dir, false, true)
4308 }
4309
4310 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4317 Self::from_data_dir_impl(data_dir, false, false)
4318 }
4319
4320 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4348 let connect_path = data_dir.join("connect.json");
4349 match std::fs::read_to_string(&connect_path) {
4350 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4351 Ok(connect) => {
4352 let legacy_key_present = !connect_config_is_empty(&self.connect);
4353 if legacy_key_present {
4354 tracing::warn!(
4355 "config.json still has a legacy `connect` key alongside \
4356 connect.json; connect.json takes precedence — dropping the \
4357 stale key from config.json now"
4358 );
4359 }
4360 self.connect = connect;
4361 if legacy_key_present {
4362 strip_legacy_connect_key_from_config_json(data_dir);
4363 }
4364 }
4365 Err(e) => {
4366 tracing::error!(
4367 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4368 connect config instead of falling back to a legacy config.json copy",
4369 connect_path,
4370 e
4371 );
4372 quarantine_corrupt_connect(&connect_path);
4373 self.connect = ConnectConfig::default();
4374 }
4375 },
4376 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4377 if !connect_config_is_empty(&self.connect) {
4378 tracing::info!(
4379 "Migrating legacy `connect` config from config.json to a \
4380 standalone connect.json"
4381 );
4382 strip_legacy_connect_key_from_config_json(data_dir);
4386 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4387 tracing::error!("Failed to write connect.json during migration: {}", e);
4388 }
4389 }
4390 }
4391 Err(e) => {
4392 tracing::error!(
4393 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4394 connect_path,
4395 e
4396 );
4397 self.connect = ConnectConfig::default();
4398 }
4399 }
4400 }
4401
4402 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4405 serde_json::from_str::<Config>(content).map(|mut config| {
4406 config.hydrate_proxy_auth_from_encrypted();
4407 config.hydrate_provider_api_keys_from_encrypted();
4408 config.hydrate_provider_instance_api_keys_from_encrypted();
4409 config.hydrate_mcp_secrets_from_encrypted();
4410 config.hydrate_env_vars_from_encrypted();
4411 config.hydrate_cluster_fabric_from_encrypted();
4412 config.hydrate_broker_token_from_encrypted();
4413 config.hydrate_notifications_from_encrypted();
4414 config.hydrate_connect_platform_tokens_from_encrypted();
4415 config.normalize_tool_settings();
4416 config.normalize_skill_settings();
4417 config
4418 })
4419 }
4420
4421 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4427 let config_path = data_dir.join("config.json");
4428 for gen in 0..BAK_GENERATIONS {
4429 let backup = backup_path_for(&config_path, gen);
4430 let Ok(content) = std::fs::read_to_string(&backup) else {
4431 continue;
4432 };
4433 match Self::parse_and_hydrate(&content) {
4434 Ok(config) => {
4435 tracing::info!("Recovered configuration from {:?}", backup);
4436 return Some((config, gen));
4437 }
4438 Err(e) => {
4439 tracing::warn!(
4440 "Backup {:?} is unparseable ({}); trying an older generation",
4441 backup,
4442 e
4443 );
4444 }
4445 }
4446 }
4447 None
4448 }
4449
4450 const SALVAGE_MAX_KEYS: usize = 512;
4456
4457 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4484 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4487 let corrupt_obj = corrupt.as_object()?;
4488 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4489 tracing::warn!(
4490 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4491 corrupt_obj.len(),
4492 Self::SALVAGE_MAX_KEYS
4493 );
4494 return None;
4495 }
4496
4497 let mut base = Self::load_backup(data_dir)
4501 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4502 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4503 let base_obj = base.as_object_mut()?;
4504
4505 let mut salvaged: Vec<String> = Vec::new();
4506 for (key, value) in corrupt_obj {
4507 let previous = base_obj.insert(key.clone(), value.clone());
4508 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4512 salvaged.push(key.clone());
4513 } else {
4514 match previous {
4515 Some(prev) => {
4516 base_obj.insert(key.clone(), prev);
4517 }
4518 None => {
4519 base_obj.remove(key);
4520 }
4521 }
4522 }
4523 }
4524
4525 tracing::warn!(
4526 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4527 last-known-good/default value",
4528 salvaged.len(),
4529 salvaged.join(", ")
4530 );
4531
4532 let rebuilt = serde_json::to_string(&base).ok()?;
4536 Self::parse_and_hydrate(&rebuilt)
4537 .ok()
4538 .map(|config| (config, salvaged))
4539 }
4540
4541 pub fn get_model(&self) -> Option<String> {
4549 if self.features.provider_model_ref {
4550 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4551 return Some(model_ref.model.clone());
4552 }
4553 }
4554 let provider = self.effective_default_provider();
4555 if let Some(instance) = self.provider_instances.get(provider) {
4556 return instance
4557 .model
4558 .clone()
4559 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4560 }
4561 match provider {
4562 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4563 "anthropic" => self
4564 .providers
4565 .anthropic
4566 .as_ref()
4567 .and_then(|c| c.model.clone()),
4568 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4569 "copilot" => Some(
4570 self.providers
4571 .copilot
4572 .as_ref()
4573 .and_then(|c| c.model.clone())
4574 .unwrap_or_else(|| "gpt-4o".to_string()),
4575 ),
4576 _ => None,
4577 }
4578 }
4579
4580 pub fn get_fast_model(&self) -> Option<String> {
4588 if self.features.provider_model_ref {
4589 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4590 return Some(model_ref.model.clone());
4591 }
4592 }
4593 let provider = self.effective_default_provider();
4594 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4595 instance.fast_model.clone()
4596 } else {
4597 match provider {
4598 "openai" => self
4599 .providers
4600 .openai
4601 .as_ref()
4602 .and_then(|c| c.fast_model.clone()),
4603 "anthropic" => self
4604 .providers
4605 .anthropic
4606 .as_ref()
4607 .and_then(|c| c.fast_model.clone()),
4608 "gemini" => self
4609 .providers
4610 .gemini
4611 .as_ref()
4612 .and_then(|c| c.fast_model.clone()),
4613 "copilot" => self
4614 .providers
4615 .copilot
4616 .as_ref()
4617 .and_then(|c| c.fast_model.clone()),
4618 _ => None,
4619 }
4620 };
4621 fast.or_else(|| self.get_model())
4622 }
4623
4624 pub fn get_task_summary_model(&self) -> Option<String> {
4632 if self.features.provider_model_ref {
4633 if let Some(model_ref) = self
4634 .defaults
4635 .as_ref()
4636 .and_then(|d| d.task_summary.as_ref())
4637 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4638 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4639 {
4640 return Some(model_ref.model.clone());
4641 }
4642 }
4643
4644 self.get_fast_model()
4645 }
4646
4647 pub fn get_memory_background_model(&self) -> Option<String> {
4655 if self.features.provider_model_ref {
4656 if let Some(model_ref) = self
4657 .defaults
4658 .as_ref()
4659 .and_then(|d| d.memory_background.as_ref())
4660 {
4661 return Some(model_ref.model.clone());
4662 }
4663 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4664 return Some(model_ref.model.clone());
4665 }
4666 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4667 return Some(model_ref.model.clone());
4668 }
4669 }
4670 let configured = self
4671 .memory
4672 .as_ref()
4673 .and_then(|memory| memory.background_model.as_ref())
4674 .map(|value| value.trim())
4675 .filter(|value| !value.is_empty())
4676 .map(ToString::to_string);
4677 configured.or_else(|| self.get_fast_model())
4678 }
4679
4680 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4688 let raw = self
4689 .default_work_area
4690 .as_ref()
4691 .and_then(|config| config.path.as_ref())
4692 .map(|value| value.trim())
4693 .filter(|value| !value.is_empty())?;
4694
4695 let candidate = expand_user_path(raw);
4696 if candidate.is_absolute() {
4697 let canonical = std::fs::canonicalize(&candidate).ok();
4698 return canonical
4699 .as_ref()
4700 .filter(|path| path.is_dir())
4701 .map(|_| candidate.clone())
4702 .or_else(|| candidate.is_dir().then_some(candidate));
4703 }
4704
4705 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4706 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4707 canonical
4708 .as_ref()
4709 .filter(|path| path.is_dir())
4710 .map(|_| from_bamboo_dir.clone())
4711 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4712 .or_else(|| candidate.is_dir().then_some(candidate))
4713 }
4714
4715 pub fn get_vision_model(&self) -> Option<String> {
4721 if self.features.provider_model_ref {
4722 if let Some(model_ref) = self
4723 .defaults
4724 .as_ref()
4725 .and_then(|defaults| defaults.vision.as_ref())
4726 .or_else(|| {
4727 self.defaults
4728 .as_ref()
4729 .and_then(|defaults| defaults.fast.as_ref())
4730 })
4731 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4732 {
4733 return Some(model_ref.model.clone());
4734 }
4735 }
4736 let provider = self.effective_default_provider();
4737 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4738 instance.vision_model.clone()
4739 } else {
4740 match provider {
4741 "openai" => self
4742 .providers
4743 .openai
4744 .as_ref()
4745 .and_then(|c| c.vision_model.clone()),
4746 "anthropic" => self
4747 .providers
4748 .anthropic
4749 .as_ref()
4750 .and_then(|c| c.vision_model.clone()),
4751 "gemini" => self
4752 .providers
4753 .gemini
4754 .as_ref()
4755 .and_then(|c| c.vision_model.clone()),
4756 "copilot" => self
4757 .providers
4758 .copilot
4759 .as_ref()
4760 .and_then(|c| c.vision_model.clone()),
4761 _ => None,
4762 }
4763 };
4764 vision.or_else(|| self.get_fast_model())
4765 }
4766
4767 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4771 if self.features.provider_model_ref {
4772 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4773 return chat
4774 .reasoning_effort
4775 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4776 }
4777 }
4778 self.reasoning_effort_for_key(self.effective_default_provider())
4779 }
4780
4781 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4791 let trimmed = key.trim();
4792 if trimmed.is_empty() {
4793 return None;
4794 }
4795
4796 if let Some(instance) = self.provider_instances.get(trimmed) {
4798 return instance.reasoning_effort;
4799 }
4800
4801 match trimmed {
4803 "openai" => self
4804 .providers
4805 .openai
4806 .as_ref()
4807 .and_then(|c| c.reasoning_effort),
4808 "anthropic" => self
4809 .providers
4810 .anthropic
4811 .as_ref()
4812 .and_then(|c| c.reasoning_effort),
4813 "gemini" => self
4814 .providers
4815 .gemini
4816 .as_ref()
4817 .and_then(|c| c.reasoning_effort),
4818 "copilot" => self
4819 .providers
4820 .copilot
4821 .as_ref()
4822 .and_then(|c| c.reasoning_effort),
4823 "bodhi" => self
4824 .providers
4825 .bodhi
4826 .as_ref()
4827 .and_then(|c| c.reasoning_effort),
4828 _ => None,
4829 }
4830 }
4831
4832 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4839 self.tools
4840 .disabled
4841 .iter()
4842 .map(|name| name.trim())
4843 .filter(|name| !name.is_empty())
4844 .map(str::to_string)
4845 .collect()
4846 }
4847
4848 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4853 self.disabled_tool_references()
4854 .into_iter()
4855 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4856 .collect()
4857 }
4858
4859 pub fn normalize_tool_settings(&mut self) {
4861 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4862 }
4863
4864 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4866 self.skills
4867 .disabled
4868 .iter()
4869 .map(|id| id.trim())
4870 .filter(|id| !id.is_empty())
4871 .map(|id| id.to_string())
4872 .collect()
4873 }
4874
4875 pub fn normalize_skill_settings(&mut self) {
4877 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4878 }
4879
4880 pub fn normalize_plugin_trust_settings(&mut self) {
4888 self.plugin_trust.trusted_hosts = self
4889 .plugin_trust
4890 .trusted_hosts
4891 .iter()
4892 .map(|entry| entry.trim().to_ascii_lowercase())
4893 .filter(|entry| !entry.is_empty())
4894 .collect();
4895 }
4896
4897 pub fn effective_default_provider(&self) -> &str {
4902 self.default_provider_instance
4903 .as_deref()
4904 .unwrap_or(&self.provider)
4905 }
4906
4907 pub fn has_provider_instances(&self) -> bool {
4909 !self.provider_instances.is_empty()
4910 }
4911
4912 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4914 self.env_vars
4915 .iter()
4916 .filter(|e| !e.value.trim().is_empty())
4917 .map(|e| (e.name.clone(), e.value.clone()))
4918 .collect()
4919 }
4920
4921 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4922 self.env_vars
4923 .iter()
4924 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4925 .map(|entry| PromptSafeEnvVarEntry {
4926 name: entry.name.clone(),
4927 secret: entry.secret,
4928 description: entry
4929 .description
4930 .as_ref()
4931 .map(|value| value.trim().to_string())
4932 .filter(|value| !value.is_empty()),
4933 })
4934 .collect()
4935 }
4936
4937 pub fn publish_env_vars(&self) {
4939 let map = self.env_vars_as_map();
4940
4941 #[cfg(any(test, feature = "test-utils"))]
4942 if crate::test_support::env_vars_cache_override_is_active() {
4943 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4944 return;
4945 }
4946
4947 let mut env_guard = env_vars_cache().write().recover_poison();
4948 *env_guard = map;
4949
4950 let prompt_safe = self.prompt_safe_env_vars();
4951 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4952 *prompt_guard = prompt_safe;
4953 }
4954
4955 pub fn current_env_vars() -> HashMap<String, String> {
4957 #[cfg(any(test, feature = "test-utils"))]
4958 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4959 return env_vars;
4960 }
4961
4962 env_vars_cache().read().recover_poison().clone()
4963 }
4964
4965 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4967 #[cfg(any(test, feature = "test-utils"))]
4968 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4969 return env_vars;
4970 }
4971
4972 prompt_safe_env_vars_cache().read().recover_poison().clone()
4973 }
4974
4975 fn create_default() -> Self {
4977 Self::from_parts(
4978 ConfigValues {
4979 http_proxy: String::new(),
4980 https_proxy: String::new(),
4981 proxy_auth: None,
4982 proxy_auth_encrypted: None,
4983 proxy_auth_credential_ref: None,
4984 headless_auth: false,
4985 run_budget: RunBudgetConfig::default(),
4986 stream_timeout: StreamTimeoutConfig::default(),
4987 llm_retry: None,
4988 context_management: ContextManagementConfig::default(),
4989 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4990 provider: default_provider(),
4991 provider_instances: HashMap::new(),
4992 default_provider_instance: None,
4993 server: ServerConfig::default(),
4994 keyword_masking: KeywordMaskingConfig::default(),
4995 anthropic_model_mapping: AnthropicModelMapping::default(),
4996 gemini_model_mapping: GeminiModelMapping::default(),
4997 patrol_model: None,
4998 hooks: HooksConfig::default(),
4999 lifecycle_hooks: LifecycleHooksConfig::default(),
5000 tools: ToolsConfig::default(),
5001 skills: SkillsConfig::default(),
5002 env_vars: Vec::new(),
5003 default_work_area: None,
5004 worktrees: WorktreeSettings::default(),
5005 access_control: None,
5006 features: FeatureFlags::default(),
5007 defaults: None,
5008 mcp: bamboo_domain::mcp_config::McpConfig::default(),
5009 notifications: NotificationsConfig::default(),
5010 connect: ConnectConfig::default(),
5011 plugin_trust: PluginTrustConfig::default(),
5012 extra: BTreeMap::new(),
5013 },
5014 None,
5015 SubagentsConfig::default(),
5016 ProviderConfigs::default(),
5017 )
5018 }
5019
5020 pub fn server_addr(&self) -> String {
5022 format!("{}:{}", self.server.bind, self.server.port)
5023 }
5024
5025 pub fn save(&self) -> Result<()> {
5027 self.save_to_dir(default_data_dir())
5028 }
5029
5030 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5033 self.memory.save_sync(data_dir)
5034 }
5035
5036 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5039 self.subagents.save_sync(data_dir)
5040 }
5041
5042 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5046 crate::config_module::ensure_legacy_module_layout(data_dir)?;
5047 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5048 let mut config = self.clone();
5049 config.clear_legacy_provider_aliases_for_instance_mode();
5050 config.refresh_provider_api_keys_encrypted()?;
5051 config.providers.save_sync(data_dir)
5052 }
5053
5054 pub(crate) fn prepare_provider_transaction_documents(
5058 &self,
5059 provider_document: &[u8],
5060 ) -> Result<(Vec<u8>, Vec<u8>)> {
5061 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5062 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5063 anyhow::bail!(
5064 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5065 status.source
5066 );
5067 }
5068
5069 let mut to_save = self.clone();
5070 to_save.clear_legacy_provider_aliases_for_instance_mode();
5071 to_save.extra.remove("data_dir");
5072 to_save.extra.remove("model");
5073 to_save.refresh_encrypted_secrets()?;
5074 to_save.ensure_provider_instance_credentials_isolated()?;
5075 to_save.sanitize_mcp_credential_refs_for_disk();
5076 to_save.sanitize_env_vars_for_disk();
5077 to_save.sanitize_notifications_for_disk();
5078 to_save.sanitize_cluster_fabric_for_disk();
5079 to_save.assign_connect_platform_ids();
5080 to_save.normalize_tool_settings();
5081 to_save.normalize_skill_settings();
5082
5083 let mut root = durable_root_value(to_save.values.clone())
5084 .context("Failed to serialize root config DTO to JSON")?;
5085 if let Some(object) = root.as_object_mut() {
5086 object.remove("connect");
5087 }
5088 let root = serde_json::to_vec_pretty(&root)?;
5089
5090 let mut providers = to_save.providers.0.clone();
5091 macro_rules! sanitize_provider {
5092 ($field:ident) => {
5093 if let Some(provider) = providers.$field.as_mut() {
5094 if !provider.api_key.trim().is_empty()
5095 && !provider.api_key_from_env
5096 && provider.credential_ref.is_none()
5097 {
5098 anyhow::bail!(
5099 "provider secret requires credential transaction before persistence"
5100 );
5101 }
5102 provider.api_key_encrypted = None;
5103 }
5104 };
5105 }
5106 sanitize_provider!(openai);
5107 sanitize_provider!(anthropic);
5108 sanitize_provider!(gemini);
5109 if let Some(provider) = providers.bodhi.as_mut() {
5110 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5111 anyhow::bail!("provider secret requires credential transaction before persistence");
5112 }
5113 provider.api_key_encrypted = None;
5114 }
5115
5116 let existing_provider_value = if provider_document.is_empty() {
5117 None
5118 } else {
5119 Some(
5120 serde_json::from_slice::<Value>(provider_document)
5121 .context("provider metadata document is invalid")?,
5122 )
5123 };
5124 let provider_value = match existing_provider_value {
5125 Some(Value::Object(mut envelope))
5126 if envelope.contains_key("schema_version")
5127 || envelope.contains_key("revision")
5128 || envelope.contains_key("data") =>
5129 {
5130 let revision = envelope
5131 .get("revision")
5132 .and_then(Value::as_u64)
5133 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5134 let schema_version = envelope
5135 .get("schema_version")
5136 .and_then(Value::as_u64)
5137 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5138 if schema_version != 1 || !envelope.contains_key("data") {
5139 anyhow::bail!("provider revision envelope is unsupported");
5140 }
5141 let revision = revision
5142 .checked_add(1)
5143 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5144 envelope.insert("revision".into(), Value::from(revision));
5145 envelope.insert("data".into(), serde_json::to_value(providers)?);
5146 Value::Object(envelope)
5147 }
5148 Some(_) | None => serde_json::to_value(providers)?,
5149 };
5150 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5151 }
5152
5153 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5157 self.recovery_status.as_ref()
5158 }
5159
5160 pub fn confirm_recovery(&mut self) {
5166 if let Some(status) = self.recovery_status.as_mut() {
5167 status.confirmed = true;
5168 }
5169 }
5170
5171 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5179 if self.recovery_status.is_none() {
5180 anyhow::bail!("No pending config-corruption recovery to confirm");
5181 }
5182 self.confirm_recovery();
5183 self.save_to_dir(data_dir)?;
5184 self.recovery_status = None;
5185 Ok(())
5186 }
5187
5188 pub fn assign_connect_platform_ids(&mut self) {
5204 for platform in &mut self.connect.platforms {
5205 if platform.id.is_none() {
5206 platform.id = Some(uuid::Uuid::new_v4().to_string());
5207 }
5208 }
5209 }
5210
5211 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5223 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5224 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5225 anyhow::bail!(
5226 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5227 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5228 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5229 status.source,
5230 status.quarantine_path,
5231 );
5232 }
5233 if self.proxy_auth_credential_ref.is_none()
5234 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5235 {
5236 anyhow::bail!(
5237 "proxy auth requires the isolated credential transaction before persistence"
5238 );
5239 }
5240 if self.env_vars.iter().any(|entry| {
5241 entry.secret
5242 && entry.credential_ref.is_none()
5243 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5244 }) {
5245 anyhow::bail!(
5246 "secret env vars require the isolated credential transaction before persistence"
5247 );
5248 }
5249 if [
5250 (
5251 &self.notifications.ntfy.token,
5252 &self.notifications.ntfy.token_encrypted,
5253 &self.notifications.ntfy.credential_ref,
5254 ),
5255 (
5256 &self.notifications.bark.device_key,
5257 &self.notifications.bark.device_key_encrypted,
5258 &self.notifications.bark.credential_ref,
5259 ),
5260 ]
5261 .iter()
5262 .any(|(plaintext, ciphertext, reference)| {
5263 reference.is_none()
5264 && (plaintext
5265 .as_deref()
5266 .is_some_and(|value| !value.trim().is_empty())
5267 || ciphertext.is_some())
5268 }) {
5269 anyhow::bail!(
5270 "notification secrets require the isolated credential transaction before persistence"
5271 );
5272 }
5273
5274 if crate::modular_authority_boundary_present(&data_dir)
5275 .context("Failed to inspect modular configuration authority boundary")?
5276 {
5277 crate::ConfigFacade::open_or_migrate(&data_dir)
5278 .context("Failed to recover modular configuration before persistence")?;
5279 crate::persist_facade_effective_config(&data_dir, self)
5280 .context("Failed to persist modular configuration sections")?;
5281 return Ok(());
5282 }
5283
5284 if crate::section_layout_is_active(&data_dir)
5285 .context("Failed to inspect modular configuration layout")?
5286 {
5287 crate::persist_facade_effective_config(&data_dir, self)
5288 .context("Failed to persist modular configuration sections")?;
5289 return Ok(());
5290 }
5291
5292 let path = data_dir.join("config.json");
5293
5294 if let Some(parent) = path.parent() {
5295 std::fs::create_dir_all(parent)
5296 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5297 }
5298
5299 let mut to_save = self.clone();
5300 to_save.clear_legacy_provider_aliases_for_instance_mode();
5301 to_save.extra.remove("data_dir");
5303 to_save.extra.remove("model");
5305 to_save.refresh_encrypted_secrets()?;
5308 to_save.ensure_provider_instance_credentials_isolated()?;
5309 to_save.sanitize_mcp_credential_refs_for_disk();
5310 to_save.sanitize_env_vars_for_disk();
5311 to_save.sanitize_notifications_for_disk();
5312 to_save.sanitize_cluster_fabric_for_disk();
5313 to_save.assign_connect_platform_ids();
5314 to_save.normalize_tool_settings();
5315 to_save.normalize_skill_settings();
5316
5317 let mut config_value = durable_root_value(to_save.values.clone())
5327 .context("Failed to serialize root config DTO to JSON")?;
5328 if let Some(obj) = config_value.as_object_mut() {
5329 obj.remove("connect");
5330 }
5331 let content = serde_json::to_string_pretty(&config_value)
5332 .context("Failed to serialize config to JSON")?;
5333
5334 to_save.memory.save_sync(&data_dir)?;
5341 to_save.subagents.save_sync(&data_dir)?;
5342 to_save.providers.save_sync(&data_dir)?;
5343
5344 if path.exists()
5351 && std::fs::read_to_string(&path)
5352 .ok()
5353 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5354 {
5355 rotate_backups(&path, BAK_GENERATIONS);
5359 let backup = backup_path_for(&path, 0);
5360 let backup_result = std::fs::read(&path).and_then(|bytes| {
5361 let mut value: Value = serde_json::from_slice(&bytes)
5362 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5363 if sanitize_ref_backed_mcp_json(&mut value) {
5364 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5365 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5366 })?;
5367 write_atomic(&backup, &sanitized)
5368 } else {
5369 std::fs::copy(&path, &backup).map(|_| ())
5370 }
5371 });
5372 if let Err(e) = backup_result {
5373 tracing::warn!("Failed to back up config.json before save: {}", e);
5374 }
5375 scrub_ref_backed_mcp_from_config_backups(&path);
5376 }
5377
5378 write_atomic(&path, content.as_bytes())
5379 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5380
5381 save_connect_config(&to_save.connect, &data_dir)?;
5382
5383 Ok(())
5384 }
5385}
5386
5387fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5392 let Some(object) = root.as_object_mut() else {
5393 return false;
5394 };
5395 let Some(mcp) = (if object.contains_key("mcpServers") {
5396 object.get_mut("mcpServers")
5397 } else {
5398 object.get_mut("mcp")
5399 }) else {
5400 return false;
5401 };
5402 let mut changed = false;
5403 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5404 for server in servers {
5405 if let Some(object) = server.as_object_mut() {
5406 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5407 {
5408 changed |= sanitize_ref_backed_mcp_transport(transport);
5409 }
5410 }
5411 }
5412 } else if let Some(servers) = mcp.as_object_mut() {
5413 for server in servers.values_mut() {
5414 if let Some(object) = server.as_object_mut() {
5415 changed |= sanitize_ref_backed_mcp_transport(object);
5416 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5417 {
5418 changed |= sanitize_ref_backed_mcp_transport(transport);
5419 }
5420 }
5421 }
5422 }
5423 changed
5424}
5425
5426fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5427 let mut changed = false;
5428 let env_names = object
5429 .get("env_credential_refs")
5430 .and_then(Value::as_object)
5431 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5432 .unwrap_or_default();
5433 for name in env_names {
5434 for field in ["env", "env_encrypted"] {
5435 if object
5436 .get_mut(field)
5437 .and_then(Value::as_object_mut)
5438 .and_then(|values| values.remove(&name))
5439 .is_some()
5440 {
5441 changed = true;
5442 }
5443 }
5444 }
5445 let header_names = object
5446 .get("header_credential_refs")
5447 .and_then(Value::as_object)
5448 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5449 .unwrap_or_default();
5450 for name in header_names {
5451 for field in ["headers", "headers_encrypted"] {
5452 if object
5453 .get_mut(field)
5454 .and_then(Value::as_object_mut)
5455 .and_then(|values| values.remove(&name))
5456 .is_some()
5457 {
5458 changed = true;
5459 }
5460 }
5461 }
5462 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5463 for header in headers {
5464 let Some(header) = header.as_object_mut() else {
5465 continue;
5466 };
5467 if header.get("credential_ref").is_some_and(Value::is_string) {
5468 changed |= header.remove("value").is_some();
5469 changed |= header.remove("value_encrypted").is_some();
5470 }
5471 }
5472 }
5473 changed
5474}
5475
5476fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5477 for generation in 0..BAK_GENERATIONS {
5478 let backup = backup_path_for(path, generation);
5479 let Ok(bytes) = std::fs::read(&backup) else {
5480 continue;
5481 };
5482 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5483 continue;
5484 };
5485 if sanitize_ref_backed_mcp_json(&mut value) {
5486 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5487 if let Err(error) = write_atomic(&backup, &sanitized) {
5488 tracing::warn!(
5489 "Failed to scrub MCP credentials from {:?}: {}",
5490 backup,
5491 error
5492 );
5493 }
5494 }
5495 }
5496 }
5497}
5498
5499fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5509 let path = data_dir.join("connect.json");
5510 if connect_config_is_empty(connect) && !path.exists() {
5511 return Ok(());
5512 }
5513
5514 if path.exists() {
5515 let backup = path.with_extension("json.bak");
5516 if let Err(e) = std::fs::copy(&path, &backup) {
5517 tracing::warn!("Failed to back up connect.json before save: {}", e);
5518 }
5519 }
5520
5521 let content = serde_json::to_string_pretty(connect)
5522 .context("Failed to serialize connect config to JSON")?;
5523 write_atomic(&path, content.as_bytes())
5524 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5525 Ok(())
5526}
5527
5528fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5546 let config_path = data_dir.join("config.json");
5547 let content = match std::fs::read_to_string(&config_path) {
5548 Ok(content) => content,
5549 Err(e) => {
5550 tracing::error!(
5551 "Failed to read config.json to strip legacy `connect` key: {}",
5552 e
5553 );
5554 return;
5555 }
5556 };
5557 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5558 Ok(value) => value,
5559 Err(e) => {
5560 tracing::error!(
5561 "Failed to parse config.json to strip legacy `connect` key: {}",
5562 e
5563 );
5564 return;
5565 }
5566 };
5567 let Some(obj) = value.as_object_mut() else {
5568 return;
5569 };
5570 if obj.remove("connect").is_none() {
5571 return;
5574 }
5575 let rewritten = match serde_json::to_string_pretty(&value) {
5576 Ok(rewritten) => rewritten,
5577 Err(e) => {
5578 tracing::error!(
5579 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5580 e
5581 );
5582 return;
5583 }
5584 };
5585 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5586 tracing::error!(
5587 "Failed to write config.json after stripping legacy `connect` key: {}",
5588 e
5589 );
5590 }
5591}
5592
5593fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5630 let config_path = data_dir.join("config.json");
5631 for gen in 0..BAK_GENERATIONS {
5632 let backup = backup_path_for(&config_path, gen);
5633 let content = match std::fs::read_to_string(&backup) {
5634 Ok(content) => content,
5635 Err(e) => {
5636 if e.kind() != std::io::ErrorKind::NotFound {
5637 tracing::warn!(
5638 "Failed to read {:?} while scanning for legacy connect data ({}); \
5639 leaving it untouched",
5640 backup,
5641 e
5642 );
5643 }
5644 continue;
5645 }
5646 };
5647 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5648 Ok(value) => value,
5649 Err(e) => {
5650 tracing::warn!(
5651 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5652 ({}); left untouched (never deleted)",
5653 backup,
5654 e
5655 );
5656 continue;
5657 }
5658 };
5659 let Some(obj) = value.as_object_mut() else {
5660 continue;
5663 };
5664 if obj.remove("connect").is_none() {
5665 continue;
5668 }
5669 let rewritten = match serde_json::to_string_pretty(&value) {
5670 Ok(rewritten) => rewritten,
5671 Err(e) => {
5672 tracing::error!(
5673 "Failed to serialize {:?} after stripping legacy connect data: {}",
5674 backup,
5675 e
5676 );
5677 continue;
5678 }
5679 };
5680 match write_atomic(&backup, rewritten.as_bytes()) {
5681 Ok(()) => tracing::info!(
5682 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5683 backup
5684 ),
5685 Err(e) => tracing::error!(
5686 "Failed to write {:?} after stripping legacy connect data: {}",
5687 backup,
5688 e
5689 ),
5690 }
5691 }
5692}
5693
5694fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5709 let backup = connect_path.with_extension("json.bak");
5710 match std::fs::rename(connect_path, &backup) {
5711 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5712 Err(e) => {
5713 tracing::warn!(
5714 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5715 backup,
5716 e
5717 );
5718 if let Err(e) = std::fs::copy(connect_path, &backup) {
5719 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5720 return;
5721 }
5722 if let Err(e) = std::fs::remove_file(connect_path) {
5723 tracing::error!(
5724 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5725 original {:?}: {}",
5726 backup,
5727 connect_path,
5728 e
5729 );
5730 }
5731 }
5732 }
5733}
5734
5735const QUARANTINE_KEEP: usize = 5;
5738
5739fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5748 let nanos = std::time::SystemTime::now()
5749 .duration_since(std::time::UNIX_EPOCH)
5750 .map(|d| d.as_nanos())
5751 .unwrap_or(0);
5752 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5756 let mut dedup = 1u32;
5757 while quarantine.exists() {
5758 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5759 dedup += 1;
5760 }
5761 let result = match std::fs::copy(config_path, &quarantine) {
5762 Ok(_) => {
5763 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5764 Some(quarantine)
5765 }
5766 Err(e) => {
5767 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5768 None
5769 }
5770 };
5771 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5772 result
5773}
5774
5775fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5778 let Some(dir) = config_path.parent() else {
5779 return;
5780 };
5781 let prefix = "config.json.corrupted.";
5782 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5783 Ok(entries) => entries
5784 .filter_map(|e| e.ok())
5785 .map(|e| e.path())
5786 .filter(|p| {
5787 p.file_name()
5788 .and_then(|n| n.to_str())
5789 .is_some_and(|n| n.starts_with(prefix))
5790 })
5791 .collect(),
5792 Err(_) => return,
5793 };
5794 if quarantines.len() <= keep {
5795 return;
5796 }
5797 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5799 let remove = quarantines.len() - keep;
5800 for stale in quarantines.into_iter().take(remove) {
5801 if let Err(e) = std::fs::remove_file(&stale) {
5802 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5803 }
5804 }
5805}
5806
5807const BAK_GENERATIONS: usize = 3;
5810
5811fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5813 if gen == 0 {
5814 config_path.with_extension("json.bak")
5815 } else {
5816 config_path.with_extension(format!("json.bak.{gen}"))
5817 }
5818}
5819
5820fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5826 for gen in (1..generations).rev() {
5827 let from = backup_path_for(config_path, gen - 1);
5828 let to = backup_path_for(config_path, gen);
5829 if from.exists() {
5830 if let Err(e) = std::fs::rename(&from, &to) {
5831 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5832 }
5833 }
5834 }
5835}
5836
5837pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5838 crate::config_store::AtomicFileStore::new(path)
5839 .write_bytes_without_backup(content)
5840 .map_err(|error| match error {
5841 crate::config_store::ConfigStoreError::Io(error) => error,
5842 other => std::io::Error::other(other),
5843 })
5844}
5845
5846#[cfg(test)]
5847mod tests {
5848 use super::*;
5849 use std::ffi::OsString;
5850 use std::path::PathBuf;
5851 use std::sync::Mutex;
5852 use std::time::{SystemTime, UNIX_EPOCH};
5853
5854 #[test]
5855 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5856 let input = serde_json::json!({
5857 "disabled": ["bash"],
5858 "plugin_runtime": {
5859 "timeout_ms": 5_000,
5860 "sandbox": true
5861 },
5862 "future_flag": "enabled"
5863 });
5864
5865 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5866 assert_eq!(config.disabled, vec!["bash"]);
5867 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5868 assert_eq!(config.extra["future_flag"], "enabled");
5869 assert_eq!(serde_json::to_value(config).unwrap(), input);
5870 }
5871
5872 #[test]
5873 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5874 let input = serde_json::json!({
5875 "tool_extension": {
5876 "mode": "strict",
5877 "options": ["one", "two"]
5878 }
5879 });
5880 let config: Config = serde_json::from_value(serde_json::json!({
5881 "tools": input.clone()
5882 }))
5883 .unwrap();
5884
5885 assert!(config.tools.disabled.is_empty());
5886 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5887 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5888
5889 let temp_home = TempHome::new();
5890 config.save_to_dir(temp_home.path.clone()).unwrap();
5891 let persisted: Value =
5892 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5893 .unwrap();
5894 assert_eq!(persisted["tools"], input);
5895 }
5896
5897 #[test]
5898 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5899 let input = serde_json::json!({
5900 "external_catalog": {
5901 "path": "/opt/bamboo/skills",
5902 "refresh": false
5903 },
5904 "schema_version": 2
5905 });
5906
5907 let config: Config = serde_json::from_value(serde_json::json!({
5908 "skills": input.clone()
5909 }))
5910 .unwrap();
5911 assert!(config.skills.disabled.is_empty());
5912 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5913 assert_eq!(config.skills.extra["schema_version"], 2);
5914 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5915
5916 let temp_home = TempHome::new();
5917 config.save_to_dir(temp_home.path.clone()).unwrap();
5918 let persisted: Value =
5919 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5920 .unwrap();
5921 assert_eq!(persisted["skills"], input);
5922 }
5923
5924 #[test]
5925 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5926 let config: Config = serde_json::from_value(serde_json::json!({
5927 "hooks": {
5928 "image_fallback": {"enabled": true, "mode": "placeholder"}
5929 },
5930 "lifecycle_hooks": {
5931 "enabled": true,
5932 "PreToolUse": [{
5933 "matcher": "bash|write_file",
5934 "hooks": [{"type": "command", "command": "guard.sh"}]
5935 }],
5936 "SessionStart": [{
5937 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5938 }]
5939 }
5940 }))
5941 .expect("lifecycle hook config should deserialize");
5942
5943 assert!(config.lifecycle_hooks.enabled);
5944 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5945 assert!(
5946 config.lifecycle_hooks.pre_tool_use[0].enabled,
5947 "legacy groups without an enabled flag remain active"
5948 );
5949 assert_eq!(
5950 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5951 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5952 );
5953 assert_eq!(
5954 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5955 25
5956 );
5957
5958 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5959 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5960 assert_eq!(
5961 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5962 "bash|write_file"
5963 );
5964 assert_eq!(
5965 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5966 "command"
5967 );
5968 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5969 .get("enabled")
5970 .is_none());
5971 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5972 .get("timeout_ms")
5973 .is_none());
5974 assert!(json.get("hooks").is_some());
5975 }
5976
5977 #[test]
5978 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5979 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5980 "type": "script",
5981 "path": ".bamboo/hooks/check.js"
5982 }))
5983 .expect("script lifecycle hook should deserialize");
5984
5985 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5986 assert!(matches!(
5987 handler,
5988 LifecycleHookHandler::Script {
5989 runner: LifecycleScriptRunner::Auto,
5990 ..
5991 }
5992 ));
5993 let json = serde_json::to_value(handler).expect("script hook should serialize");
5994 assert_eq!(json["type"], "script");
5995 assert!(json.get("timeout_ms").is_none());
5996 assert!(json.get("runner").is_none());
5997 }
5998
5999 #[test]
6000 fn script_runner_support_is_extension_aware() {
6001 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
6002 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
6003 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
6004 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
6005 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
6006 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
6007 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
6008 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
6009 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
6010 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
6011 }
6012
6013 #[test]
6014 fn script_runner_names_round_trip_through_config_json() {
6015 for (runner, name) in [
6016 (LifecycleScriptRunner::Auto, "auto"),
6017 (LifecycleScriptRunner::Node, "node"),
6018 (LifecycleScriptRunner::Bun, "bun"),
6019 (LifecycleScriptRunner::Python, "python"),
6020 (LifecycleScriptRunner::Bash, "bash"),
6021 (LifecycleScriptRunner::PowerShell, "powershell"),
6022 (LifecycleScriptRunner::Cmd, "cmd"),
6023 ] {
6024 let json = serde_json::to_value(runner).unwrap();
6025 assert_eq!(json, name);
6026 assert_eq!(
6027 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
6028 runner
6029 );
6030 }
6031 }
6032
6033 #[test]
6034 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6035 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6036 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6037 let json = serde_json::to_value(&config).unwrap();
6038 assert!(json.get("lifecycle_hooks").is_none());
6039 }
6040
6041 #[test]
6042 fn stream_timeout_defaults_are_safe_and_back_compatible() {
6043 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6044 .expect("legacy config without stream_timeout should load");
6045 let values = ConfigValues::from(root);
6046
6047 assert_eq!(
6048 values.stream_timeout,
6049 StreamTimeoutConfig {
6050 transport_idle_timeout_secs: 120,
6051 first_semantic_timeout_secs: 600,
6052 semantic_idle_timeout_secs: 600,
6053 }
6054 );
6055 values
6056 .stream_timeout
6057 .validate()
6058 .expect("defaults are valid");
6059 }
6060
6061 #[test]
6062 fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6063 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6064 .expect("legacy config without llm_retry should load");
6065 let values = ConfigValues::from(root);
6066 assert_eq!(values.llm_retry, None);
6069
6070 let defaults = LlmRetryConfig::default();
6071 assert_eq!(defaults.max_attempts, 8);
6072 assert_eq!(defaults.attempt_timeout_ms, 180_000);
6073 assert_eq!(defaults.total_budget_ms, 900_000);
6074 defaults.validate().expect("defaults are valid");
6075 }
6076
6077 #[test]
6078 fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6079 let valid = LlmRetryConfig::default();
6080 assert!(valid.validate().is_ok());
6081 for broken in [
6082 LlmRetryConfig {
6083 max_attempts: 0,
6084 ..valid
6085 },
6086 LlmRetryConfig {
6087 max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6088 ..valid
6089 },
6090 LlmRetryConfig {
6091 attempt_timeout_ms: 999,
6092 ..valid
6093 },
6094 LlmRetryConfig {
6095 attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6096 ..valid
6097 },
6098 LlmRetryConfig {
6099 total_budget_ms: 0,
6100 ..valid
6101 },
6102 LlmRetryConfig {
6103 total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6104 ..valid
6105 },
6106 LlmRetryConfig {
6108 base_delay_ms: 2_000,
6109 max_delay_ms: 1_000,
6110 ..valid
6111 },
6112 ] {
6113 assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6114 }
6115 }
6116
6117 #[test]
6118 fn llm_retry_round_trips_through_its_section() {
6119 let policy = LlmRetryConfig {
6120 max_attempts: 12,
6121 attempt_timeout_ms: 300_000,
6122 total_budget_ms: 3_600_000,
6123 base_delay_ms: 250,
6124 max_delay_ms: 60_000,
6125 };
6126 let values = ConfigValues {
6127 llm_retry: Some(policy),
6128 ..ConfigValues::default()
6129 };
6130 let json = serde_json::to_value(&values).expect("serialize");
6131 assert_eq!(json["llm_retry"]["max_attempts"], 12);
6132 let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6133 assert_eq!(round_tripped.llm_retry, Some(policy));
6134 }
6135
6136 #[test]
6137 fn stream_timeout_round_trips_through_persistence_dto() {
6138 let values = ConfigValues {
6139 stream_timeout: StreamTimeoutConfig {
6140 transport_idle_timeout_secs: 45,
6141 first_semantic_timeout_secs: 900,
6142 semantic_idle_timeout_secs: 300,
6143 },
6144 ..ConfigValues::default()
6145 };
6146
6147 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6148 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6149 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6150 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6151
6152 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6153 assert_eq!(
6154 ConfigValues::from(root).stream_timeout,
6155 StreamTimeoutConfig {
6156 transport_idle_timeout_secs: 45,
6157 first_semantic_timeout_secs: 900,
6158 semantic_idle_timeout_secs: 300,
6159 }
6160 );
6161 }
6162
6163 #[test]
6164 fn context_management_defaults_to_summary_and_is_omitted() {
6165 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6166 assert_eq!(
6167 config.context_management.strategy,
6168 ContextManagementStrategy::Summary
6169 );
6170 assert_eq!(
6171 config
6172 .context_management
6173 .retrieval_window
6174 .min_recent_user_turns,
6175 3
6176 );
6177 assert_eq!(
6178 config.context_management.retrieval_target_usage_percent(),
6179 25
6180 );
6181 let json = serde_json::to_value(&config).unwrap();
6182 assert!(json.get("context_management").is_none());
6183 }
6184
6185 #[test]
6186 fn retrieval_window_context_management_round_trips() {
6187 let json = serde_json::json!({
6188 "context_management": {
6189 "strategy": "retrieval_window",
6190 "retrieval_window": {
6191 "min_recent_user_turns": 4,
6192 "trigger_usage_ratio": 0.82,
6193 "target_usage_ratio": 0.61,
6194 "history_tool_required": true,
6195 "fallback_strategy": "summary"
6196 }
6197 }
6198 });
6199 let root: ConfigRoot = serde_json::from_value(json).unwrap();
6200 let values = ConfigValues::from(root);
6201 assert_eq!(
6202 values.context_management.strategy,
6203 ContextManagementStrategy::RetrievalWindow
6204 );
6205 assert_eq!(
6206 values.context_management.retrieval_window.fallback_strategy,
6207 ContextManagementFallbackStrategy::Summary
6208 );
6209
6210 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6211 assert_eq!(
6212 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6213 4
6214 );
6215 assert_eq!(
6216 persisted["context_management"]["strategy"],
6217 "retrieval_window"
6218 );
6219 }
6220
6221 #[test]
6222 fn message_summary_context_management_round_trips_without_changing_defaults() {
6223 let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6224 "context_management": {
6225 "strategy": "message_summary"
6226 }
6227 }))
6228 .unwrap();
6229 let values = ConfigValues::from(root);
6230 assert_eq!(
6231 values.context_management.strategy,
6232 ContextManagementStrategy::MessageSummary
6233 );
6234 assert_eq!(
6235 values.context_management.retrieval_window,
6236 RetrievalWindowContextConfig::default()
6237 );
6238
6239 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6240 assert_eq!(
6241 persisted["context_management"]["strategy"],
6242 "message_summary"
6243 );
6244 let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6245 assert_eq!(
6246 ConfigValues::from(reloaded).context_management.strategy,
6247 ContextManagementStrategy::MessageSummary
6248 );
6249 assert_eq!(
6250 ContextManagementConfig::default().strategy,
6251 ContextManagementStrategy::Summary
6252 );
6253 }
6254
6255 #[test]
6256 fn context_management_uses_one_explicit_policy_for_every_strategy() {
6257 for strategy in ["summary", "message_summary", "retrieval_window"] {
6258 let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6259 "strategy": strategy,
6260 "trigger_usage_ratio": 0.72,
6261 "target_usage_ratio": 0.20,
6262 "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6263 }))
6264 .unwrap();
6265 let budget =
6266 policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6267 assert_eq!(budget.compression_trigger_percent, 72);
6268 assert_eq!(budget.compression_target_percent, 20);
6269 let saved = serde_json::to_value(&policy).unwrap();
6270 let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6271 assert_eq!(restored, policy);
6272 }
6273 }
6274
6275 #[test]
6276 fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6277 ) {
6278 for strategy in ["summary", "message_summary", "retrieval_window"] {
6279 for (trigger, target, expected_trigger, expected_target) in
6280 [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6281 {
6282 let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6283 "strategy": strategy,
6284 "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6285 })).unwrap();
6286 assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6287 assert_eq!(policy.target_usage_ratio, expected_target);
6288 }
6289 }
6290 }
6291
6292 #[test]
6293 fn context_management_rejects_unrepresentable_shared_range() {
6294 for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6295 let value =
6296 serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6297 assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6298 }
6299 }
6300
6301 #[test]
6302 fn retrieval_window_context_management_rejects_unsafe_policy() {
6303 for invalid in [
6304 serde_json::json!({
6305 "strategy": "retrieval_window",
6306 "retrieval_window": {"min_recent_user_turns": 0}
6307 }),
6308 serde_json::json!({
6309 "strategy": "retrieval_window",
6310 "retrieval_window": {
6311 "target_usage_ratio": 0.8,
6312 "trigger_usage_ratio": 0.8
6313 }
6314 }),
6315 serde_json::json!({
6316 "strategy": "retrieval_window",
6317 "retrieval_window": {
6318 "target_usage_ratio": 0.005,
6319 "trigger_usage_ratio": 0.006
6320 }
6321 }),
6322 serde_json::json!({
6323 "strategy": "retrieval_window",
6324 "retrieval_window": {"history_tool_required": false}
6325 }),
6326 ] {
6327 serde_json::from_value::<ContextManagementConfig>(invalid)
6328 .expect_err("unsafe retrieval-window policy must fail closed");
6329 }
6330 }
6331
6332 #[test]
6333 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6334 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6335 "provider_type": "openai",
6336 "enabled": true
6337 }))
6338 .unwrap();
6339 let mut config = Config::default();
6340 config.values.provider = "gemini".to_string();
6341 config
6342 .provider_instances
6343 .insert("work".to_string(), instance);
6344 config.default_provider_instance = Some("work".to_string());
6345 config.providers_mut().openai = Some(OpenAIConfig::default());
6346
6347 let json = serde_json::to_value(&config).unwrap();
6348 assert_eq!(json["provider"], "gemini");
6349 assert!(json["providers"]["openai"].is_object());
6350 assert_eq!(json["default_provider_instance"], "work");
6351
6352 let round_trip: Config = serde_json::from_value(json).unwrap();
6353 assert_eq!(round_trip.provider, "gemini");
6354 assert_eq!(
6355 round_trip.default_provider_instance.as_deref(),
6356 Some("work")
6357 );
6358 assert!(round_trip.provider_instances.contains_key("work"));
6359 assert!(round_trip.providers().openai.is_some());
6360 }
6361
6362 #[test]
6363 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6364 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6365 let dir = tempfile::tempdir().unwrap();
6366 let mut config = Config::default();
6367 config.values.provider = "anthropic".to_string();
6368 config.provider_instances.insert(
6369 "work".to_string(),
6370 serde_json::from_value(serde_json::json!({
6371 "provider_type": "openai",
6372 "model": "gpt-instance",
6373 "enabled": true,
6374 "future_instance_metadata": "preserved"
6375 }))
6376 .unwrap(),
6377 );
6378 config.default_provider_instance = Some("work".to_string());
6379 config.features.provider_model_ref = true;
6380 config.providers_mut().openai = Some(OpenAIConfig::default());
6381 config.providers_mut().anthropic = Some(AnthropicConfig::default());
6382 config
6383 .providers_mut()
6384 .extra
6385 .insert("provider".to_string(), serde_json::json!("anthropic"));
6386 config.providers_mut().extra.insert(
6387 "future_provider".to_string(),
6388 serde_json::json!({"kept": true}),
6389 );
6390
6391 let (root_bytes, provider_bytes) =
6392 config.prepare_provider_transaction_documents(&[]).unwrap();
6393 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6394 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6395 assert!(root.get("provider").is_none());
6396 assert!(root.get("providers").is_none());
6397 assert_eq!(root["default_provider_instance"], "work");
6398 assert_eq!(
6399 root["provider_instances"]["work"]["future_instance_metadata"],
6400 "preserved"
6401 );
6402 assert!(providers.get("provider").is_none());
6403 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6404 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6405 }
6406 assert_eq!(providers["future_provider"]["kept"], true);
6407
6408 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6409 let saved_root: Value =
6410 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6411 .unwrap();
6412 let saved_providers: Value =
6413 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6414 .unwrap();
6415 assert!(saved_root.get("provider").is_none());
6416 assert!(saved_root.get("providers").is_none());
6417 assert_eq!(saved_root["default_provider_instance"], "work");
6418 assert!(saved_providers.get("openai").is_none());
6419 assert!(saved_providers.get("anthropic").is_none());
6420 assert!(saved_providers.get("provider").is_none());
6421 assert_eq!(saved_providers["future_provider"]["kept"], true);
6422
6423 config.providers_mut().gemini = Some(GeminiConfig::default());
6424 config.save_providers_to_dir(dir.path()).unwrap();
6425 let provider_only: Value =
6426 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6427 .unwrap();
6428 assert!(provider_only.get("gemini").is_none());
6429 assert!(provider_only.get("provider").is_none());
6430 assert_eq!(provider_only["future_provider"]["kept"], true);
6431 }
6432
6433 #[test]
6434 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6435 let mut config = Config::default();
6436 config.values.provider = "openai".to_string();
6437 config.providers_mut().openai = Some(OpenAIConfig {
6438 model: Some("gpt-legacy".to_string()),
6439 ..OpenAIConfig::default()
6440 });
6441 config.provider_instances.insert(
6442 "work".to_string(),
6443 serde_json::from_value(serde_json::json!({
6444 "provider_type": "copilot",
6445 "enabled": true
6446 }))
6447 .unwrap(),
6448 );
6449 config.default_provider_instance = Some("openai".to_string());
6450
6451 let (root_bytes, provider_bytes) =
6452 config.prepare_provider_transaction_documents(&[]).unwrap();
6453 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6454 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6455 assert_eq!(root["provider"], "openai");
6456 assert_eq!(root["default_provider_instance"], "openai");
6457 assert!(root["provider_instances"]["work"].is_object());
6458 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6459 }
6460
6461 #[test]
6462 fn persistence_keeps_legacy_provider_without_instance_default() {
6463 let values = ConfigValues {
6464 provider: "gemini".to_string(),
6465 ..ConfigValues::default()
6466 };
6467
6468 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6469 assert_eq!(json["provider"], "gemini");
6470 }
6471
6472 #[test]
6473 fn stream_timeout_rejects_zero_and_unbounded_values() {
6474 for (field, invalid) in [
6475 ("transport_idle_timeout_secs", 0),
6476 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6477 ("semantic_idle_timeout_secs", 0),
6478 ] {
6479 let mut timeout = serde_json::json!({});
6480 timeout[field] = serde_json::json!(invalid);
6481 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6482 .expect_err("invalid timeout must be rejected");
6483 assert!(error.to_string().contains("stream timeout must be between"));
6484 }
6485 }
6486
6487 struct EnvVarGuard {
6488 key: &'static str,
6489 previous: Option<OsString>,
6490 }
6491
6492 impl EnvVarGuard {
6493 fn set(key: &'static str, value: &str) -> Self {
6494 let previous = std::env::var_os(key);
6495 std::env::set_var(key, value);
6496 Self { key, previous }
6497 }
6498
6499 fn unset(key: &'static str) -> Self {
6500 let previous = std::env::var_os(key);
6501 std::env::remove_var(key);
6502 Self { key, previous }
6503 }
6504 }
6505
6506 impl Drop for EnvVarGuard {
6507 fn drop(&mut self) {
6508 match &self.previous {
6509 Some(value) => std::env::set_var(self.key, value),
6510 None => std::env::remove_var(self.key),
6511 }
6512 }
6513 }
6514
6515 #[test]
6516 fn run_budget_config_merge_is_tighten_only_per_field() {
6517 let config_default = RunBudgetConfig {
6518 max_total_tokens: Some(100_000),
6519 max_tool_calls: Some(500),
6520 max_subagents: Some(10),
6521 max_rounds: Some(200),
6522 };
6523
6524 assert_eq!(
6526 config_default.merged_with_override(None),
6527 config_default,
6528 "no override falls back to the config default entirely"
6529 );
6530
6531 let tighten_one = RunBudgetConfig {
6534 max_total_tokens: Some(5_000),
6535 max_tool_calls: None,
6536 max_subagents: None,
6537 max_rounds: None,
6538 };
6539 let merged = config_default.merged_with_override(Some(&tighten_one));
6540 assert_eq!(merged.max_total_tokens, Some(5_000));
6541 assert_eq!(merged.max_tool_calls, Some(500));
6542 assert_eq!(merged.max_subagents, Some(10));
6543 assert_eq!(merged.max_rounds, Some(200));
6544
6545 let loosen_attempt = RunBudgetConfig {
6548 max_total_tokens: Some(999_999_999),
6549 max_tool_calls: Some(10_000),
6550 max_subagents: Some(1_000),
6551 max_rounds: Some(100_000),
6552 };
6553 assert_eq!(
6554 config_default.merged_with_override(Some(&loosen_attempt)),
6555 config_default,
6556 "looser per-request values must be clamped to the config ceiling"
6557 );
6558
6559 let empty_override = RunBudgetConfig::default();
6563 assert_eq!(
6564 config_default.merged_with_override(Some(&empty_override)),
6565 config_default,
6566 "an all-absent override body keeps every configured ceiling"
6567 );
6568
6569 let unlimited_default = RunBudgetConfig::default();
6573 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6574 assert_eq!(merged.max_total_tokens, Some(5_000));
6575 assert_eq!(merged.max_tool_calls, None);
6576 assert_eq!(merged.max_subagents, None);
6577 assert_eq!(merged.max_rounds, None);
6578
6579 let tighten_rounds = RunBudgetConfig {
6582 max_total_tokens: None,
6583 max_tool_calls: None,
6584 max_subagents: None,
6585 max_rounds: Some(20),
6586 };
6587 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6588 assert_eq!(merged.max_rounds, Some(20));
6589 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6590 assert_eq!(
6591 merged.max_rounds,
6592 Some(20),
6593 "a stricter per-request round cap wins over the config default"
6594 );
6595 }
6596
6597 #[test]
6598 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6599 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6600 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6601 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6602 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6603
6604 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6605 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6606 assert_eq!(cfg.max_total_tokens, Some(250_000));
6607 assert_eq!(
6608 cfg.max_tool_calls, None,
6609 "absent field defaults to unlimited"
6610 );
6611 assert_eq!(
6612 cfg.max_rounds, None,
6613 "absent round cap defaults to unlimited"
6614 );
6615
6616 assert_eq!(cfg.max_subagents, Some(3));
6617
6618 let json = r#"{ "max_rounds": 250 }"#;
6620 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6621 assert_eq!(cfg.max_rounds, Some(250));
6622 let round_tripped: RunBudgetConfig =
6623 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6624 assert_eq!(round_tripped.max_rounds, Some(250));
6625 assert_eq!(round_tripped.max_total_tokens, None);
6626
6627 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6631 assert_eq!(empty, "{}");
6632 }
6633
6634 #[test]
6635 fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6636 let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6637 assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6638 assert!(!serde_json::to_string(&old)
6639 .unwrap()
6640 .contains("max_spawn_depth"));
6641 for depth in [0, 1, 8] {
6642 let config: SubagentsConfig =
6643 serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6644 assert_eq!(config.effective_max_spawn_depth(), depth);
6645 assert_eq!(
6646 serde_json::to_value(config).unwrap()["max_spawn_depth"],
6647 depth
6648 );
6649 }
6650 for invalid in [
6651 serde_json::json!(-1),
6652 serde_json::json!(1.5),
6653 serde_json::json!("4"),
6654 ] {
6655 assert!(serde_json::from_value::<SubagentsConfig>(
6656 serde_json::json!({"max_spawn_depth": invalid}),
6657 )
6658 .is_err());
6659 }
6660 }
6661
6662 #[test]
6663 fn subagents_config_without_remote_placements_deserializes_empty() {
6664 let json = r#"{ "max_concurrent": 4 }"#;
6667 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6668 assert_eq!(cfg.max_concurrent, Some(4));
6669 assert!(cfg.remote_placements.is_empty());
6670 let back = serde_json::to_string(&cfg).unwrap();
6672 assert!(
6673 !back.contains("remote_placements"),
6674 "empty vec is skipped: {back}"
6675 );
6676 }
6677
6678 #[test]
6679 fn remote_actor_placement_round_trips() {
6680 let json = r#"{
6681 "remote_placements": [
6682 {
6683 "role": "explorer",
6684 "endpoint": "wss://gpu-host:8443",
6685 "token_env": "WORKER_TOKEN",
6686 "ca_cert_file": "/etc/bamboo/worker.pem"
6687 },
6688 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6689 ]
6690 }"#;
6691 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6692 assert_eq!(cfg.remote_placements.len(), 2);
6693 let p0 = &cfg.remote_placements[0];
6694 assert_eq!(p0.role, "explorer");
6695 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6696 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6697 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6698 let p1 = &cfg.remote_placements[1];
6700 assert_eq!(p1.role, "writer");
6701 assert!(p1.token_env.is_none());
6702 assert!(p1.ca_cert_file.is_none());
6703
6704 let back = serde_json::to_string(&cfg).unwrap();
6705 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6706 assert_eq!(cfg, reparsed, "round-trip is stable");
6707 assert!(!back.contains("\"token_env\":null"));
6708 assert!(!back.contains("\"ca_cert_file\":null"));
6709 }
6710
6711 #[test]
6712 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6713 let json = r#"{ "max_concurrent": 4 }"#;
6716 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6717 assert!(cfg.schedulable_placements.is_empty());
6718 let back = serde_json::to_string(&cfg).unwrap();
6720 assert!(
6721 !back.contains("schedulable_placements"),
6722 "empty vec is skipped: {back}"
6723 );
6724 }
6725
6726 #[test]
6727 fn schedulable_placement_round_trips() {
6728 let json = r#"{
6729 "schedulable_placements": [
6730 {
6731 "role": "explorer",
6732 "pool": "gpu-pool",
6733 "registry_url": "https://control-plane:9562",
6734 "token_env": "WORKER_TOKEN",
6735 "ca_cert_file": "/etc/bamboo/worker.pem"
6736 },
6737 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6738 ]
6739 }"#;
6740 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6741 assert_eq!(cfg.schedulable_placements.len(), 2);
6742 let p0 = &cfg.schedulable_placements[0];
6743 assert_eq!(p0.role, "explorer");
6744 assert_eq!(p0.pool, "gpu-pool");
6745 assert_eq!(p0.registry_url, "https://control-plane:9562");
6746 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6747 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6748 let p1 = &cfg.schedulable_placements[1];
6750 assert_eq!(p1.role, "writer");
6751 assert_eq!(p1.pool, "cpu-pool");
6752 assert!(p1.token_env.is_none());
6753 assert!(p1.ca_cert_file.is_none());
6754
6755 let back = serde_json::to_string(&cfg).unwrap();
6756 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6757 assert_eq!(cfg, reparsed, "round-trip is stable");
6758 assert!(!back.contains("\"token_env\":null"));
6759 assert!(!back.contains("\"ca_cert_file\":null"));
6760 }
6761
6762 #[test]
6763 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6764 let json = r#"{ "max_concurrent": 4 }"#;
6768 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6769 assert!(cfg.mcp_role_allowlist.is_empty());
6770 let back = serde_json::to_string(&cfg).unwrap();
6772 assert!(
6773 !back.contains("mcp_role_allowlist"),
6774 "empty vec is skipped: {back}"
6775 );
6776 }
6777
6778 #[test]
6779 fn mcp_role_allowlist_entry_round_trips() {
6780 let json = r#"{
6781 "mcp_role_allowlist": [
6782 { "role": "researcher", "tools": ["fetch_url"] },
6783 { "role": "sandboxed", "tools": [] }
6784 ]
6785 }"#;
6786 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6787 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6788 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6789 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6790 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6793 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6794
6795 let back = serde_json::to_string(&cfg).unwrap();
6796 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6797 assert_eq!(cfg, reparsed, "round-trip is stable");
6798 }
6799
6800 #[test]
6801 fn server_config_without_tls_field_deserializes_back_compat() {
6802 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6805 "port": 9562,
6806 "bind": "127.0.0.1"
6807 }))
6808 .expect("legacy server config without tls should deserialize");
6809
6810 assert_eq!(server.tls, None);
6811 assert_eq!(server.port, 9562);
6812 assert_eq!(server.bind, "127.0.0.1");
6813 }
6814
6815 #[test]
6816 fn server_config_omits_tls_when_none() {
6817 let server = ServerConfig::default();
6820 let value = serde_json::to_value(&server).expect("server config should serialize");
6821 let obj = value
6822 .as_object()
6823 .expect("server config serializes to object");
6824 assert!(
6825 !obj.contains_key("tls"),
6826 "tls must be omitted when None, got: {value}"
6827 );
6828 }
6829
6830 #[test]
6831 fn server_config_with_tls_roundtrips() {
6832 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6833 "port": 9562,
6834 "bind": "0.0.0.0",
6835 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6836 }))
6837 .expect("server config with tls should deserialize");
6838
6839 let tls = server.tls.clone().expect("tls should be Some");
6840 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6841 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6842
6843 let value = serde_json::to_value(&server).expect("serialize");
6845 assert!(value.as_object().unwrap().contains_key("tls"));
6846 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6847 assert_eq!(back.tls, server.tls);
6848 }
6849
6850 #[test]
6851 fn access_control_without_devices_field_deserializes_back_compat() {
6852 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6855 "password_enabled": true,
6856 "password_hash": "deadbeef",
6857 "password_salt": "01020304",
6858 }))
6859 .expect("legacy access_control without devices should deserialize");
6860
6861 assert!(access.devices.is_empty());
6862 assert!(access.password_enabled);
6863 }
6864
6865 #[test]
6866 fn access_control_omits_devices_when_empty() {
6867 let access = AccessControlConfig {
6870 password_enabled: true,
6871 repair_required: false,
6872 password_hash: Some("deadbeef".to_string()),
6873 password_salt: Some("01020304".to_string()),
6874 password_credential_ref: None,
6875 password_configured: false,
6876 updated_at: None,
6877 devices: Vec::new(),
6878 };
6879 let value = serde_json::to_value(&access).expect("serialize");
6880 let obj = value.as_object().expect("object");
6881 assert!(
6882 !obj.contains_key("devices"),
6883 "devices must be omitted when empty, got: {value}"
6884 );
6885 }
6886
6887 #[test]
6888 fn access_control_with_devices_roundtrips() {
6889 let device = DeviceCredential {
6890 device_id: "bamboo_0123456789ab".to_string(),
6891 label: "iPhone 15".to_string(),
6892 token_hash: "abcd".to_string(),
6893 token_salt: "ef01".to_string(),
6894 token_credential_ref: None,
6895 token_configured: false,
6896 created_at: "2026-06-23T00:00:00Z".to_string(),
6897 last_used_at: None,
6898 revoked: false,
6899 };
6900 let access = AccessControlConfig {
6901 password_enabled: true,
6902 repair_required: false,
6903 password_hash: Some("deadbeef".to_string()),
6904 password_salt: Some("01020304".to_string()),
6905 password_credential_ref: None,
6906 password_configured: false,
6907 updated_at: None,
6908 devices: vec![device.clone()],
6909 };
6910 let value = serde_json::to_value(&access).expect("serialize");
6911 assert!(value.as_object().unwrap().contains_key("devices"));
6912 assert!(value["devices"][0].get("token_hash").is_none());
6913 assert!(value["devices"][0].get("token_salt").is_none());
6914 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6915 assert_eq!(back.devices[0].device_id, device.device_id);
6916 assert!(back.devices[0].token_hash.is_empty());
6917 assert!(back.devices[0].token_salt.is_empty());
6918 }
6919
6920 #[test]
6921 fn reasoning_effort_for_key_resolves_instance_id() {
6922 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6926 "provider_type": "copilot",
6927 "reasoning_effort": "high",
6928 }))
6929 .expect("instance config should deserialize");
6930
6931 let mut config = Config::create_default();
6932 config
6933 .provider_instances
6934 .insert("copilot-work".to_string(), instance);
6935
6936 assert_eq!(
6937 config.reasoning_effort_for_key("copilot-work"),
6938 Some(ReasoningEffort::High),
6939 );
6940 }
6941
6942 #[test]
6943 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6944 let mut config = Config::create_default();
6946 config.providers.bodhi = Some(
6947 serde_json::from_value(serde_json::json!({
6948 "reasoning_effort": "xhigh",
6949 }))
6950 .expect("bodhi config should deserialize"),
6951 );
6952
6953 assert_eq!(
6954 config.reasoning_effort_for_key("bodhi"),
6955 Some(ReasoningEffort::Xhigh),
6956 );
6957 }
6958
6959 #[test]
6960 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6961 let mut config = Config::create_default();
6962 config.providers.openai = Some(
6963 serde_json::from_value(serde_json::json!({
6964 "api_key": "sk-test",
6965 "reasoning_effort": "low",
6966 }))
6967 .expect("openai config should deserialize"),
6968 );
6969
6970 assert_eq!(
6971 config.reasoning_effort_for_key("openai"),
6972 Some(ReasoningEffort::Low),
6973 );
6974 }
6975
6976 #[test]
6977 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6978 let config = Config::create_default();
6979 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6980 assert_eq!(config.reasoning_effort_for_key(" "), None);
6981 }
6982
6983 struct TempHome {
6984 path: PathBuf,
6985 }
6986
6987 impl TempHome {
6988 fn new() -> Self {
6989 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
7001 std::sync::atomic::AtomicU64::new(0);
7002 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7003 let nanos = SystemTime::now()
7004 .duration_since(UNIX_EPOCH)
7005 .expect("clock should be after unix epoch")
7006 .as_nanos();
7007 let path = std::env::temp_dir().join(format!(
7008 "chat-core-config-test-{}-{}-{}",
7009 std::process::id(),
7010 nanos,
7011 unique
7012 ));
7013 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
7014 Self { path }
7015 }
7016
7017 fn set_config_json(&self, content: &str) {
7018 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
7021 std::fs::write(self.path.join("config.json"), content)
7022 .expect("failed to write config.json");
7023 }
7024 }
7025
7026 impl Drop for TempHome {
7027 fn drop(&mut self) {
7028 let _ = std::fs::remove_dir_all(&self.path);
7029 }
7030 }
7031
7032 fn env_lock() -> &'static Mutex<()> {
7036 crate::test_support::env_cache_lock()
7037 }
7038
7039 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7041 env_lock().lock().unwrap_or_else(|poisoned| {
7042 poisoned.into_inner()
7044 })
7045 }
7046
7047 #[test]
7048 fn parse_bool_env_true_values() {
7049 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7050 assert!(parse_bool_env(value), "value {value:?} should be true");
7051 }
7052 }
7053
7054 #[test]
7055 fn parse_bool_env_false_values() {
7056 for value in ["0", "false", "no", "off", "", " "] {
7057 assert!(!parse_bool_env(value), "value {value:?} should be false");
7058 }
7059 }
7060
7061 #[test]
7062 fn config_new_ignores_http_proxy_env_vars() {
7063 let _lock = env_lock_acquire();
7064 let temp_home = TempHome::new();
7065 temp_home.set_config_json(
7066 r#"{
7067 "http_proxy": "",
7068 "https_proxy": ""
7069}"#,
7070 );
7071
7072 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7073 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7074
7075 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7076
7077 assert!(
7078 config.http_proxy.is_empty(),
7079 "config should ignore HTTP_PROXY env var"
7080 );
7081 assert!(
7082 config.https_proxy.is_empty(),
7083 "config should ignore HTTPS_PROXY env var"
7084 );
7085 }
7086
7087 #[test]
7088 fn config_new_loads_config_when_proxy_fields_omitted() {
7089 let _lock = env_lock_acquire();
7090 let temp_home = TempHome::new();
7091 temp_home.set_config_json(
7092 r#"{
7093 "provider": "openai",
7094 "providers": {
7095 "openai": {
7096 "api_key": "sk-test",
7097 "model": "gpt-4o"
7098 }
7099 }
7100}"#,
7101 );
7102
7103 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7104 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7105
7106 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7107
7108 assert_eq!(
7109 config
7110 .providers
7111 .openai
7112 .as_ref()
7113 .and_then(|c| c.model.as_deref()),
7114 Some("gpt-4o"),
7115 "config should load provider model from config file even when proxy fields are omitted"
7116 );
7117 assert!(config.http_proxy.is_empty());
7118 assert!(config.https_proxy.is_empty());
7119 }
7120
7121 #[test]
7122 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7123 let _lock = crate::test_support::env_cache_lock_acquire();
7124 let mut config = Config::default();
7125 config.env_vars.extend([
7126 EnvVarEntry {
7127 name: "SECRET_TOKEN".to_string(),
7128 value: "top-secret".to_string(),
7129 secret: true,
7130 value_encrypted: None,
7131 credential_ref: None,
7132 configured: true,
7133 description: Some("Service token".to_string()),
7134 },
7135 EnvVarEntry {
7136 name: "API_BASE".to_string(),
7137 value: "https://internal.example".to_string(),
7138 secret: false,
7139 value_encrypted: None,
7140 credential_ref: None,
7141 configured: true,
7142 description: Some("Internal API base".to_string()),
7143 },
7144 ]);
7145
7146 config.publish_env_vars();
7147
7148 let injected = Config::current_env_vars();
7149 assert_eq!(
7150 injected.get("SECRET_TOKEN").map(String::as_str),
7151 Some("top-secret")
7152 );
7153 assert_eq!(
7154 injected.get("API_BASE").map(String::as_str),
7155 Some("https://internal.example")
7156 );
7157
7158 let prompt_safe = Config::current_prompt_safe_env_vars();
7159 assert_eq!(prompt_safe.len(), 2);
7160 assert!(prompt_safe.iter().any(|entry| {
7161 entry.name == "SECRET_TOKEN"
7162 && entry.secret
7163 && entry.description.as_deref() == Some("Service token")
7164 }));
7165 assert!(prompt_safe.iter().any(|entry| {
7166 entry.name == "API_BASE"
7167 && !entry.secret
7168 && entry.description.as_deref() == Some("Internal API base")
7169 }));
7170 assert!(!prompt_safe
7171 .iter()
7172 .any(|entry| entry.name.contains("top-secret")));
7173 assert!(!prompt_safe.iter().any(|entry| {
7174 entry
7175 .description
7176 .as_deref()
7177 .is_some_and(|value| value.contains("https://internal.example"))
7178 }));
7179 }
7180
7181 #[test]
7182 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7183 let _lock = crate::test_support::env_cache_lock_acquire();
7184
7185 let mut live = Config::default();
7187 live.env_vars.extend([EnvVarEntry {
7188 name: "BAMBOO_CACHE_OWNER_40".to_string(),
7189 value: "live".to_string(),
7190 secret: false,
7191 value_encrypted: None,
7192 credential_ref: None,
7193 configured: true,
7194 description: None,
7195 }]);
7196 live.publish_env_vars();
7197 assert_eq!(
7198 Config::current_env_vars()
7199 .get("BAMBOO_CACHE_OWNER_40")
7200 .map(String::as_str),
7201 Some("live")
7202 );
7203
7204 let temp = TempHome::new();
7206 temp.set_config_json(
7207 &serde_json::json!({
7208 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7209 })
7210 .to_string(),
7211 );
7212
7213 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7216 assert_eq!(
7217 loaded
7218 .env_vars
7219 .iter()
7220 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7221 .map(|e| e.value.as_str()),
7222 Some("stale-disk"),
7223 "the returned Config holds the disk value"
7224 );
7225 assert_eq!(
7226 Config::current_env_vars()
7227 .get("BAMBOO_CACHE_OWNER_40")
7228 .map(String::as_str),
7229 Some("live"),
7230 "but the global cache is UNTOUCHED — no clobber (#40)"
7231 );
7232
7233 let _ = Config::from_data_dir(Some(temp.path.clone()));
7235 assert_eq!(
7236 Config::current_env_vars()
7237 .get("BAMBOO_CACHE_OWNER_40")
7238 .map(String::as_str),
7239 Some("stale-disk"),
7240 "the publishing loader clobbers the cache (contrast)"
7241 );
7242 }
7243
7244 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7245 std::fs::read_dir(dir)
7246 .unwrap()
7247 .filter_map(|e| e.ok())
7248 .any(|e| {
7249 e.file_name()
7250 .to_string_lossy()
7251 .contains("config.json.corrupted.")
7252 })
7253 }
7254
7255 #[test]
7256 fn corrupt_config_recovered_from_backup_and_quarantined() {
7257 let temp = TempHome::new();
7258 std::fs::write(
7260 temp.path.join("config.json.bak"),
7261 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7262 )
7263 .unwrap();
7264 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7266
7267 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7268 assert_eq!(
7269 config.http_proxy, "http://from-backup",
7270 "recovered from config.json.bak instead of losing all config"
7271 );
7272 assert!(
7273 dir_has_quarantine_file(&temp.path),
7274 "corrupt config.json was quarantined (preserved), not discarded"
7275 );
7276 }
7277
7278 #[test]
7279 fn corrupt_config_without_backup_quarantines_then_defaults() {
7280 let temp = TempHome::new();
7281 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7282
7283 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7284 assert!(
7285 config.http_proxy.is_empty(),
7286 "no backup -> falls back to defaults"
7287 );
7288 assert!(
7289 dir_has_quarantine_file(&temp.path),
7290 "corrupt config.json is quarantined even when there's no backup"
7291 );
7292 }
7293
7294 #[test]
7295 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7296 let temp = TempHome::new();
7297 temp.set_config_json(
7302 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7303 );
7304
7305 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7306 assert_eq!(
7307 config.http_proxy, "http://salvaged",
7308 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7309 );
7310 assert!(
7311 config.env_vars.is_empty(),
7312 "the corrupt field reset to its default instead of failing the whole load"
7313 );
7314 assert!(
7315 dir_has_quarantine_file(&temp.path),
7316 "the corrupt config.json was still quarantined for inspection"
7317 );
7318 }
7319
7320 #[test]
7321 fn salvage_preferred_over_backup_for_most_recent_intent() {
7322 let temp = TempHome::new();
7323 std::fs::write(
7325 temp.path.join("config.json.bak"),
7326 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7327 )
7328 .unwrap();
7329 temp.set_config_json(
7331 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7332 );
7333
7334 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7335 assert_eq!(
7336 config.http_proxy, "http://new-salvaged",
7337 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7338 );
7339 }
7340
7341 #[test]
7342 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7343 let temp = TempHome::new();
7344 std::fs::write(
7346 temp.path.join("config.json.bak"),
7347 serde_json::json!({
7348 "http_proxy": "http://old-from-backup",
7349 "https_proxy": "https://kept-from-backup",
7350 })
7351 .to_string(),
7352 )
7353 .unwrap();
7354 temp.set_config_json(
7357 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7358 );
7359
7360 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7361 assert_eq!(
7363 config.http_proxy, "http://newer-edit",
7364 "the corrupt file's newer valid edit is applied"
7365 );
7366 assert_eq!(
7368 config.https_proxy, "https://kept-from-backup",
7369 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7370 );
7371 }
7372
7373 #[test]
7374 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7375 let temp = TempHome::new();
7376 std::fs::write(
7377 temp.path.join("config.json.bak"),
7378 serde_json::json!({
7379 "providers": {
7380 "anthropic": { "model": "claude-backup" }
7381 },
7382 "memory": {
7383 "auto_dream_enabled": true
7384 },
7385 "subagents": {
7386 "claude_code_model": "claude-code-backup"
7387 }
7388 })
7389 .to_string(),
7390 )
7391 .unwrap();
7392 assert!(!temp.path.join("providers.json").exists());
7393 assert!(!temp.path.join("memory.json").exists());
7394 assert!(!temp.path.join("subagents.json").exists());
7395
7396 let (salvaged, recovered_fields) = Config::salvage_partial(
7397 r#"{"providers":"schema-invalid-provider-section"}"#,
7398 &temp.path,
7399 )
7400 .expect("object-shaped corrupt config should be salvageable");
7401
7402 assert!(
7403 recovered_fields.is_empty(),
7404 "the schema-invalid provider field must not replace the backup baseline"
7405 );
7406 assert_eq!(
7407 salvaged
7408 .providers()
7409 .anthropic
7410 .as_ref()
7411 .and_then(|provider| provider.model.as_deref()),
7412 Some("claude-backup")
7413 );
7414 assert!(
7415 salvaged
7416 .memory()
7417 .as_ref()
7418 .expect("backup memory config survives")
7419 .auto_dream_enabled
7420 );
7421 assert_eq!(
7422 salvaged.subagents().claude_code_model.as_deref(),
7423 Some("claude-code-backup")
7424 );
7425 }
7426
7427 #[test]
7428 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7429 let temp = TempHome::new();
7430 std::fs::write(
7433 temp.path.join("config.json.bak"),
7434 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7435 )
7436 .unwrap();
7437 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7438
7439 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7440 assert_eq!(
7441 config.http_proxy, "http://from-backup",
7442 "garbage (non-object) config skips salvage and recovers from .bak"
7443 );
7444 }
7445
7446 #[test]
7447 fn quarantine_files_are_capped_to_newest_n() {
7448 let temp = TempHome::new();
7449 let config_path = temp.path.join("config.json");
7450 std::fs::write(&config_path, "{}").unwrap();
7451
7452 for _ in 0..(QUARANTINE_KEEP + 3) {
7455 quarantine_corrupt_config(&config_path);
7456 std::thread::sleep(std::time::Duration::from_millis(3));
7457 }
7458
7459 let count = std::fs::read_dir(&temp.path)
7460 .unwrap()
7461 .filter_map(|e| e.ok())
7462 .filter(|e| {
7463 e.file_name()
7464 .to_string_lossy()
7465 .starts_with("config.json.corrupted.")
7466 })
7467 .count();
7468 assert_eq!(
7469 count, QUARANTINE_KEEP,
7470 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7471 );
7472 }
7473
7474 #[test]
7475 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7476 let temp = TempHome::new();
7477 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7479 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7480 std::fs::write(
7481 temp.path.join("config.json.bak.1"),
7482 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7483 )
7484 .unwrap();
7485
7486 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7487 assert_eq!(
7488 config.http_proxy, "http://from-gen-1",
7489 "recovered from .bak.1 when both config.json and .bak are corrupt"
7490 );
7491 }
7492
7493 #[test]
7494 fn save_rotates_backup_generations() {
7495 let temp = TempHome::new();
7496 let path = temp.path.join("config.json");
7497 std::fs::write(
7499 &path,
7500 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7501 )
7502 .unwrap();
7503
7504 let mut cfg = Config::create_default();
7505 cfg.http_proxy = "http://proxy-v2".to_string();
7507 cfg.save_to_dir(temp.path.clone()).unwrap();
7508 cfg.http_proxy = "http://proxy-v3".to_string();
7510 cfg.save_to_dir(temp.path.clone()).unwrap();
7511
7512 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7513 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7514 assert!(
7515 bak.contains("proxy-v2"),
7516 ".bak holds the previous generation (v2)"
7517 );
7518 assert!(
7519 bak1.contains("proxy-v1"),
7520 ".bak.1 holds the older rotated generation (v1)"
7521 );
7522 }
7523
7524 #[test]
7525 fn save_backs_up_existing_config() {
7526 let temp = TempHome::new();
7527 std::fs::write(
7529 temp.path.join("config.json"),
7530 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7531 )
7532 .unwrap();
7533
7534 let mut config = Config::create_default();
7535 config.http_proxy = "http://new".to_string();
7536 config
7537 .save_to_dir(temp.path.clone())
7538 .expect("save succeeds");
7539
7540 let backup =
7541 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7542 assert!(
7543 backup.contains("http://old"),
7544 "config.json.bak holds the PREVIOUS config (last-known-good)"
7545 );
7546 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7547 assert!(
7548 current.contains("http://new"),
7549 "config.json holds the new config"
7550 );
7551 }
7552
7553 #[test]
7554 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7555 let temp = TempHome::new();
7556 std::fs::write(
7558 temp.path.join("config.json.bak"),
7559 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7560 )
7561 .unwrap();
7562 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7565
7566 let mut config = Config::create_default();
7567 config.http_proxy = "http://new".to_string();
7568 config
7569 .save_to_dir(temp.path.clone())
7570 .expect("save succeeds");
7571
7572 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7574 assert!(
7575 backup.contains("http://good-bak"),
7576 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7577 );
7578 }
7579
7580 #[test]
7583 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7584 let temp = TempHome::new();
7585 std::fs::write(
7586 temp.path.join("config.json.bak"),
7587 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7588 )
7589 .unwrap();
7590 let corrupt_bytes = "{ not valid json ";
7591 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7592
7593 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7594 let status = config
7595 .recovery_status()
7596 .expect("a corrupt load must set a pending recovery status");
7597 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7598 assert_eq!(
7599 status.source,
7600 ConfigRecoverySource::Backup { generation: 0 },
7601 "recovered from generation-0 (.bak)"
7602 );
7603 let quarantine_path = status
7604 .quarantine_path
7605 .as_ref()
7606 .expect("quarantine copy should have succeeded");
7607 assert_eq!(
7608 std::fs::read_to_string(quarantine_path).unwrap(),
7609 corrupt_bytes,
7610 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7611 );
7612 assert_eq!(
7613 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7614 corrupt_bytes,
7615 "the original config.json itself is untouched by the load (only copied, not moved)"
7616 );
7617 }
7618
7619 #[test]
7620 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7621 let temp = TempHome::new();
7622 temp.set_config_json(
7623 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7624 );
7625
7626 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7627 let status = config.recovery_status().expect("pending recovery");
7628 assert!(!status.confirmed);
7629 match &status.source {
7630 ConfigRecoverySource::Salvaged { fields } => {
7631 assert!(
7632 fields.iter().any(|f| f == "http_proxy"),
7633 "salvaged fields should list the recovered key: {fields:?}"
7634 );
7635 }
7636 other => panic!("expected Salvaged source, got {other:?}"),
7637 }
7638 }
7639
7640 #[test]
7641 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7642 let temp = TempHome::new();
7643 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7645
7646 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7647 let status = config.recovery_status().expect("pending recovery");
7648 assert!(!status.confirmed);
7649 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7650 }
7651
7652 #[test]
7653 fn clean_load_never_sets_recovery_status() {
7654 let temp = TempHome::new();
7655 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7656
7657 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7658 assert!(
7659 config.recovery_status().is_none(),
7660 "a config.json that parses cleanly must never carry a pending recovery status"
7661 );
7662 }
7663
7664 #[test]
7665 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7666 let temp = TempHome::new();
7667 let corrupt_bytes = "}}} broken";
7668 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7669
7670 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7671 assert!(config.recovery_status().is_some());
7672
7673 let err = config
7674 .save_to_dir(temp.path.clone())
7675 .expect_err("save must refuse while recovery is unconfirmed");
7676 assert!(
7677 err.to_string().contains("recovered from corruption")
7678 || err.to_string().contains("confirm"),
7679 "error should explain the refused overwrite: {err}"
7680 );
7681
7682 assert_eq!(
7685 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7686 corrupt_bytes,
7687 "a refused save must leave the corrupt original untouched"
7688 );
7689 }
7690
7691 #[test]
7692 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7693 let temp = TempHome::new();
7694 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7696 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7697
7698 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7699 let status = config.recovery_status().expect("pending recovery");
7700 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7701 assert_eq!(
7702 std::fs::read_to_string(quarantine_path).unwrap(),
7703 truncated,
7704 "truncated original preserved byte for byte in quarantine"
7705 );
7706
7707 let err = config.save_to_dir(temp.path.clone());
7708 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7709 assert_eq!(
7710 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7711 truncated,
7712 "the half-written original stays exactly as it was after a refused save"
7713 );
7714 }
7715
7716 #[test]
7717 fn confirm_recovery_allows_the_next_save() {
7718 let temp = TempHome::new();
7719 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7720
7721 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7722 assert!(config.recovery_status().is_some());
7723
7724 config.confirm_recovery();
7725 assert!(
7726 config.recovery_status().is_some_and(|s| s.confirmed),
7727 "confirm_recovery flips the flag but keeps the status around"
7728 );
7729
7730 config
7731 .save_to_dir(temp.path.clone())
7732 .expect("save must succeed once the recovery is confirmed");
7733 }
7734
7735 #[test]
7736 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7737 let temp = TempHome::new();
7738 std::fs::write(
7739 temp.path.join("config.json"),
7740 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7741 )
7742 .unwrap();
7743
7744 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7745 assert!(config.recovery_status().is_some());
7746 let quarantine_path = config
7747 .recovery_status()
7748 .unwrap()
7749 .quarantine_path
7750 .clone()
7751 .unwrap();
7752
7753 config
7754 .confirm_recovery_and_save_to_dir(temp.path.clone())
7755 .expect("confirm+save should succeed");
7756
7757 assert!(
7758 config.recovery_status().is_none(),
7759 "the pending flag is cleared once the recovery is confirmed and persisted"
7760 );
7761 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7762 assert!(
7763 on_disk.contains("http://recovered"),
7764 "config.json now holds the recovered (salvaged) state"
7765 );
7766 assert!(
7769 quarantine_path.exists(),
7770 "the quarantined original survives confirmation — it's never deleted"
7771 );
7772 }
7773
7774 #[test]
7775 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7776 let temp = TempHome::new();
7777 let mut config = Config::create_default();
7778 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7779 assert!(
7780 err.is_err(),
7781 "confirming a recovery that was never pending must error, not silently succeed"
7782 );
7783 }
7784
7785 fn connect_platform_with_encrypted(
7788 platform_type: &str,
7789 token_encrypted: &str,
7790 ) -> ConnectPlatformConfig {
7791 ConnectPlatformConfig {
7792 id: None,
7793 project_id: None,
7794 platform_type: platform_type.to_string(),
7795 token: None,
7796 token_encrypted: Some(token_encrypted.to_string()),
7797 token_credential_ref: None,
7798 token_configured: false,
7799 app_id: None,
7800 app_secret: None,
7801 app_secret_encrypted: None,
7802 app_secret_credential_ref: None,
7803 app_secret_configured: false,
7804 domain: None,
7805 allow_from: vec!["user-1".to_string()],
7806 admin_from: Vec::new(),
7807 }
7808 }
7809
7810 fn connect_json_path(temp: &TempHome) -> PathBuf {
7811 temp.path.join("connect.json")
7812 }
7813
7814 #[test]
7815 fn save_splits_connect_into_sibling_connect_json() {
7816 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7817 let temp = TempHome::new();
7818
7819 let mut config = Config::create_default();
7820 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7821 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7822
7823 config
7824 .save_to_dir(temp.path.clone())
7825 .expect("save succeeds");
7826
7827 let config_json: serde_json::Value =
7828 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7829 .unwrap();
7830 assert!(
7831 config_json.get("connect").is_none(),
7832 "config.json must not carry the `connect` key after a save"
7833 );
7834
7835 let connect_json: serde_json::Value =
7836 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7837 .unwrap();
7838 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7839 assert!(
7840 connect_json["platforms"][0]["token_encrypted"]
7841 .as_str()
7842 .is_some_and(|v| !v.is_empty()),
7843 "the token is persisted in its encrypted form in connect.json"
7844 );
7845 assert!(
7846 connect_json["platforms"][0].get("token").is_none(),
7847 "the plaintext token is never persisted (skip_serializing)"
7848 );
7849 }
7850
7851 #[test]
7854 fn save_assigns_a_missing_connect_platform_id() {
7855 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7856 let temp = TempHome::new();
7857
7858 let mut config = Config::create_default();
7859 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7860 assert!(
7861 config.connect.platforms[0].id.is_none(),
7862 "precondition: the entry starts without an id"
7863 );
7864
7865 config
7866 .save_to_dir(temp.path.clone())
7867 .expect("save succeeds");
7868
7869 let connect_json: serde_json::Value =
7870 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7871 .unwrap();
7872 let persisted_id = connect_json["platforms"][0]["id"]
7873 .as_str()
7874 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7875 assert!(!persisted_id.is_empty());
7876
7877 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7878 assert_eq!(
7879 reloaded.connect.platforms[0].id.as_deref(),
7880 Some(persisted_id),
7881 "the assigned id round-trips through a reload"
7882 );
7883 }
7884
7885 #[test]
7886 fn save_never_reassigns_an_existing_connect_platform_id() {
7887 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7888 let temp = TempHome::new();
7889
7890 let mut config = Config::create_default();
7891 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7892 platform.id = Some("stable-id-123".to_string());
7893 config.connect.platforms = vec![platform];
7894
7895 config
7896 .save_to_dir(temp.path.clone())
7897 .expect("first save succeeds");
7898 config
7900 .save_to_dir(temp.path.clone())
7901 .expect("second save succeeds");
7902
7903 let connect_json: serde_json::Value =
7904 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7905 .unwrap();
7906 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7907 }
7908
7909 #[test]
7910 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7911 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7912 let temp = TempHome::new();
7913
7914 let mut config = Config::create_default();
7915 config.connect.platforms = vec![
7916 connect_platform_with_encrypted("telegram", "cipher-a"),
7917 connect_platform_with_encrypted("telegram", "cipher-b"),
7918 ];
7919
7920 config
7921 .save_to_dir(temp.path.clone())
7922 .expect("save succeeds");
7923
7924 let connect_json: serde_json::Value =
7925 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7926 .unwrap();
7927 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7928 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7929 assert_ne!(
7930 id_a, id_b,
7931 "two entries sharing platform_type must still get distinct ids"
7932 );
7933 }
7934
7935 #[test]
7936 fn load_never_assigns_or_persists_an_id_by_itself() {
7937 let temp = TempHome::new();
7938 std::fs::write(
7939 connect_json_path(&temp),
7940 serde_json::json!({
7941 "platforms": [
7942 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7943 ]
7944 })
7945 .to_string(),
7946 )
7947 .unwrap();
7948 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7949
7950 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7951
7952 assert!(
7953 config.connect.platforms[0].id.is_none(),
7954 "load alone must not backfill an id in memory"
7955 );
7956 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7957 assert_eq!(
7958 connect_json_before, connect_json_after,
7959 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7960 );
7961 }
7962
7963 #[test]
7964 fn load_merges_connect_json_into_config() {
7965 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7966 let temp = TempHome::new();
7967 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7968 std::fs::write(
7969 connect_json_path(&temp),
7970 serde_json::json!({
7971 "platforms": [
7972 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7973 ]
7974 })
7975 .to_string(),
7976 )
7977 .unwrap();
7978
7979 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7980 assert_eq!(config.connect.platforms.len(), 1);
7981 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7982 assert_eq!(
7983 config.connect.platforms[0].token.as_deref(),
7984 Some("connect-secret")
7985 );
7986 assert!(config.connect.platforms[0].token_encrypted.is_none());
7987 }
7988
7989 #[test]
7990 fn load_without_connect_json_yields_empty_inert_connect_config() {
7991 let temp = TempHome::new();
7992 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7993
7994 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7995 assert!(
7996 config.connect.platforms.is_empty(),
7997 "no connect.json and no legacy key -> empty/inert connect config"
7998 );
7999 assert!(
8000 !connect_json_path(&temp).exists(),
8001 "load must not create connect.json when there is nothing to migrate"
8002 );
8003 }
8004
8005 #[test]
8006 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
8007 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
8008 let temp = TempHome::new();
8009 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
8010 let legacy_cipher_for_assert = legacy_cipher.clone();
8011 temp.set_config_json(
8013 &serde_json::json!({
8014 "http_proxy": "http://keep-me",
8015 "connect": {
8016 "platforms": [
8017 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
8018 ]
8019 }
8020 })
8021 .to_string(),
8022 );
8023
8024 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8025
8026 assert_eq!(config.connect.platforms.len(), 1);
8028 assert_eq!(
8029 config.connect.platforms[0].token.as_deref(),
8030 Some("legacy-connect-secret")
8031 );
8032 assert_eq!(config.http_proxy, "http://keep-me");
8034
8035 assert!(
8037 connect_json_path(&temp).exists(),
8038 "migration proactively creates connect.json"
8039 );
8040 let connect_json: serde_json::Value =
8041 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8042 .unwrap();
8043 assert_eq!(
8044 connect_json["platforms"][0]["token_encrypted"],
8045 legacy_cipher_for_assert
8046 );
8047
8048 let config_json: serde_json::Value =
8050 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8051 .unwrap();
8052 assert!(
8053 config_json.get("connect").is_none(),
8054 "config.json is rewritten without the legacy `connect` key"
8055 );
8056 }
8057
8058 #[test]
8066 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8067 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8068 let temp = TempHome::new();
8069
8070 let original_api_key_encrypted =
8071 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8072 temp.set_config_json(
8073 &serde_json::json!({
8074 "providers": {
8075 "openai": {
8076 "api_key_encrypted": original_api_key_encrypted,
8077 }
8078 },
8079 "connect": {
8080 "platforms": [
8081 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8082 ]
8083 }
8084 })
8085 .to_string(),
8086 );
8087
8088 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8089 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8090
8091 assert!(
8094 !temp.path.join("config.json.bak").exists(),
8095 "a read-only load migrating a legacy `connect` key must not rotate \
8096 config.json backups"
8097 );
8098
8099 let config_json: serde_json::Value =
8104 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8105 .unwrap();
8106 assert_eq!(
8107 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8108 "an unrelated secret's ciphertext must not be touched by the connect \
8109 migration's narrow write"
8110 );
8111 assert!(
8112 config_json.get("connect").is_none(),
8113 "config.json is still rewritten without the legacy `connect` key"
8114 );
8115 }
8116
8117 #[test]
8118 fn both_files_present_connect_json_wins() {
8119 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8120 let temp = TempHome::new();
8121 let stale = crate::encryption::encrypt("stale-secret").unwrap();
8122 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8123 temp.set_config_json(
8124 &serde_json::json!({
8125 "connect": {
8126 "platforms": [
8127 { "type": "telegram", "token_encrypted": stale }
8128 ]
8129 }
8130 })
8131 .to_string(),
8132 );
8133 std::fs::write(
8134 connect_json_path(&temp),
8135 serde_json::json!({
8136 "platforms": [
8137 { "type": "telegram", "token_encrypted": authoritative }
8138 ]
8139 })
8140 .to_string(),
8141 )
8142 .unwrap();
8143
8144 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8145 assert_eq!(
8146 config.connect.platforms[0].token.as_deref(),
8147 Some("authoritative-secret"),
8148 "connect.json wins over a stale legacy config.json key"
8149 );
8150 }
8151
8152 #[test]
8157 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8158 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8159 let temp = TempHome::new();
8160 let stale = crate::encryption::encrypt("stale-secret").unwrap();
8161 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8162 temp.set_config_json(
8163 &serde_json::json!({
8164 "http_proxy": "http://keep-me",
8165 "connect": {
8166 "platforms": [
8167 { "type": "telegram", "token_encrypted": stale }
8168 ]
8169 }
8170 })
8171 .to_string(),
8172 );
8173 std::fs::write(
8174 connect_json_path(&temp),
8175 serde_json::json!({
8176 "platforms": [
8177 { "type": "telegram", "token_encrypted": authoritative }
8178 ]
8179 })
8180 .to_string(),
8181 )
8182 .unwrap();
8183
8184 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8185 assert_eq!(
8186 config.connect.platforms[0].token.as_deref(),
8187 Some("authoritative-secret")
8188 );
8189 assert_eq!(config.http_proxy, "http://keep-me");
8191
8192 let config_json: serde_json::Value =
8193 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8194 .unwrap();
8195 assert!(
8196 config_json.get("connect").is_none(),
8197 "the stale legacy `connect` key must be stripped from config.json \
8198 immediately on load, not left for the next natural save"
8199 );
8200 }
8201
8202 #[test]
8210 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8211 let temp = TempHome::new();
8212 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8213 std::fs::write(
8214 temp.path.join("config.json.bak"),
8215 serde_json::json!({
8216 "http_proxy": "http://old",
8217 "connect": {
8218 "platforms": [
8219 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8220 ]
8221 }
8222 })
8223 .to_string(),
8224 )
8225 .unwrap();
8226
8227 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8228 assert!(config.connect.platforms.is_empty());
8231
8232 let bak: serde_json::Value = serde_json::from_str(
8233 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8234 )
8235 .unwrap();
8236 assert!(
8237 bak.get("connect").is_none(),
8238 "the legacy `connect` key must be stripped from the tainted .bak generation"
8239 );
8240 assert_eq!(
8241 bak["http_proxy"], "http://old",
8242 "every other key in the .bak generation survives the scrub byte-for-byte in content"
8243 );
8244 }
8245
8246 #[test]
8251 fn scrub_reaches_all_rotated_generations() {
8252 let temp = TempHome::new();
8253 temp.set_config_json(&serde_json::json!({}).to_string());
8254 for (gen_suffix, cipher) in [
8255 ("config.json.bak", "cipher-gen0"),
8256 ("config.json.bak.1", "cipher-gen1"),
8257 ("config.json.bak.2", "cipher-gen2"),
8258 ] {
8259 std::fs::write(
8260 temp.path.join(gen_suffix),
8261 serde_json::json!({
8262 "connect": {
8263 "platforms": [
8264 { "type": "telegram", "token_encrypted": cipher }
8265 ]
8266 }
8267 })
8268 .to_string(),
8269 )
8270 .unwrap();
8271 }
8272
8273 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8274
8275 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8276 let value: serde_json::Value =
8277 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8278 .unwrap();
8279 assert!(
8280 value.get("connect").is_none(),
8281 "{gen_suffix} must have its legacy `connect` key stripped"
8282 );
8283 }
8284 }
8285
8286 #[test]
8294 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8295 let temp = TempHome::new();
8296 temp.set_config_json(&serde_json::json!({}).to_string());
8297 let bak_path = temp.path.join("config.json.bak");
8298 std::fs::write(
8299 &bak_path,
8300 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8301 )
8302 .unwrap();
8303
8304 let before_bytes = std::fs::read(&bak_path).unwrap();
8305 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8306
8307 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8311
8312 let after_bytes = std::fs::read(&bak_path).unwrap();
8313 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8314 assert_eq!(
8315 before_bytes, after_bytes,
8316 "a .bak generation without a legacy `connect` key must not be rewritten at all"
8317 );
8318 assert_eq!(
8319 before_mtime, after_mtime,
8320 "no write means no mtime change either"
8321 );
8322 }
8323
8324 #[test]
8329 fn scrub_skips_unparsable_backup_without_deleting_it() {
8330 let temp = TempHome::new();
8331 temp.set_config_json(&serde_json::json!({}).to_string());
8332 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8333
8334 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8335
8336 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8337 assert_eq!(
8338 content, "{ not valid json",
8339 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8340 );
8341 }
8342
8343 #[test]
8347 fn scrub_tolerates_missing_generations() {
8348 let temp = TempHome::new();
8349 temp.set_config_json(&serde_json::json!({}).to_string());
8350 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8352 assert!(config.connect.platforms.is_empty());
8353 assert!(!temp.path.join("config.json.bak").exists());
8354 }
8355
8356 #[test]
8359 fn scrub_does_not_break_backup_rotation() {
8360 let temp = TempHome::new();
8361 std::fs::write(
8362 temp.path.join("config.json"),
8363 serde_json::json!({
8364 "http_proxy": "http://proxy-v1",
8365 "connect": {
8366 "platforms": [
8367 { "type": "telegram", "token_encrypted": "legacy-cipher" }
8368 ]
8369 }
8370 })
8371 .to_string(),
8372 )
8373 .unwrap();
8374 std::fs::write(
8375 temp.path.join("config.json.bak"),
8376 serde_json::json!({
8377 "http_proxy": "http://proxy-v0",
8378 "connect": {
8379 "platforms": [
8380 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8381 ]
8382 }
8383 })
8384 .to_string(),
8385 )
8386 .unwrap();
8387
8388 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8390 let bak: serde_json::Value = serde_json::from_str(
8391 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8392 )
8393 .unwrap();
8394 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8395
8396 config.http_proxy = "http://proxy-v2".to_string();
8399 config.save_to_dir(temp.path.clone()).unwrap();
8400
8401 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8402 assert!(
8403 new_bak.contains("proxy-v1"),
8404 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8405 );
8406 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8407 assert!(
8408 new_bak1.contains("proxy-v0"),
8409 ".bak.1 holds the scrubbed older generation after rotation"
8410 );
8411 assert!(
8412 !new_bak1.contains("legacy-bak-cipher"),
8413 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8414 stripped secret"
8415 );
8416 }
8417
8418 #[test]
8419 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8420 let temp = TempHome::new();
8421 temp.set_config_json("{}");
8422 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8423
8424 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8425 assert!(
8426 config.connect.platforms.is_empty(),
8427 "corrupt connect.json fails SAFE to an empty/inert connect config"
8428 );
8429
8430 let backup = connect_json_path(&temp).with_extension("json.bak");
8431 assert!(
8432 backup.exists(),
8433 "the corrupt connect.json is quarantined to connect.json.bak"
8434 );
8435 assert!(
8436 std::fs::read_to_string(backup)
8437 .unwrap()
8438 .contains("not valid json"),
8439 "the quarantined copy holds the bad content"
8440 );
8441 assert!(
8446 !connect_json_path(&temp).exists(),
8447 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8448 connect.json should remain after quarantine"
8449 );
8450 }
8451
8452 #[test]
8453 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8454 let temp = TempHome::new();
8455 temp.set_config_json(
8458 &serde_json::json!({
8459 "connect": {
8460 "platforms": [
8461 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8462 ]
8463 }
8464 })
8465 .to_string(),
8466 );
8467 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8468
8469 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8470 assert!(
8471 config.connect.platforms.is_empty(),
8472 "corrupt connect.json must not fall back to the legacy config.json copy"
8473 );
8474 }
8475
8476 #[test]
8477 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8478 let temp = TempHome::new();
8479 let config = Config::create_default();
8480 assert!(config.connect.platforms.is_empty());
8481
8482 config
8483 .save_to_dir(temp.path.clone())
8484 .expect("save succeeds");
8485
8486 assert!(
8487 !connect_json_path(&temp).exists(),
8488 "an empty connect config with no pre-existing file must not create one"
8489 );
8490 }
8491
8492 #[test]
8493 fn connect_json_backed_up_before_overwrite() {
8494 let temp = TempHome::new();
8495 std::fs::write(
8496 connect_json_path(&temp),
8497 serde_json::json!({
8498 "platforms": [
8499 { "type": "telegram", "token_encrypted": "old-cipher" }
8500 ]
8501 })
8502 .to_string(),
8503 )
8504 .unwrap();
8505
8506 let mut config = Config::create_default();
8507 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8508 config
8509 .save_to_dir(temp.path.clone())
8510 .expect("save succeeds");
8511
8512 let backup = connect_json_path(&temp).with_extension("json.bak");
8513 assert!(
8514 std::fs::read_to_string(backup)
8515 .unwrap()
8516 .contains("old-cipher"),
8517 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8518 );
8519 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8520 assert!(current.contains("new-cipher"));
8521 }
8522
8523 #[test]
8526 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8527 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8528 let temp = TempHome::new();
8529
8530 let mut config = Config::create_default();
8531 config.connect.platforms = vec![ConnectPlatformConfig {
8532 id: None,
8533 project_id: None,
8534 platform_type: "feishu".to_string(),
8535 token: None,
8536 token_encrypted: None,
8537 token_credential_ref: None,
8538 token_configured: false,
8539 app_id: Some("cli_real_app_id".to_string()),
8540 app_secret: Some("plain-app-secret".to_string()),
8541 app_secret_encrypted: None,
8542 app_secret_credential_ref: None,
8543 app_secret_configured: false,
8544 domain: Some("lark".to_string()),
8545 allow_from: vec!["ou_1".to_string()],
8546 admin_from: Vec::new(),
8547 }];
8548
8549 config
8550 .save_to_dir(temp.path.clone())
8551 .expect("save succeeds");
8552
8553 let connect_json: serde_json::Value =
8554 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8555 .unwrap();
8556 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8557 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8558 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8559 assert!(
8560 connect_json["platforms"][0]["app_secret_encrypted"]
8561 .as_str()
8562 .is_some_and(|v| !v.is_empty()),
8563 "app_secret is persisted in its encrypted form in connect.json"
8564 );
8565 assert!(
8566 connect_json["platforms"][0].get("app_secret").is_none(),
8567 "the plaintext app_secret is never persisted (skip_serializing)"
8568 );
8569 }
8570
8571 #[test]
8572 fn load_hydrates_feishu_app_secret_from_encrypted() {
8573 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8574 let temp = TempHome::new();
8575
8576 let mut config = Config::create_default();
8577 config.connect.platforms = vec![ConnectPlatformConfig {
8578 id: None,
8579 project_id: None,
8580 platform_type: "feishu".to_string(),
8581 token: None,
8582 token_encrypted: None,
8583 token_credential_ref: None,
8584 token_configured: false,
8585 app_id: Some("cli_real_app_id".to_string()),
8586 app_secret: Some("plain-app-secret".to_string()),
8587 app_secret_encrypted: None,
8588 app_secret_credential_ref: None,
8589 app_secret_configured: false,
8590 domain: Some("lark".to_string()),
8591 allow_from: vec!["ou_1".to_string()],
8592 admin_from: Vec::new(),
8593 }];
8594 config
8595 .save_to_dir(temp.path.clone())
8596 .expect("save succeeds");
8597
8598 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8599 assert_eq!(reloaded.connect.platforms.len(), 1);
8600 assert_eq!(
8601 reloaded.connect.platforms[0].app_secret.as_deref(),
8602 Some("plain-app-secret"),
8603 "reload hydrates app_secret from app_secret_encrypted"
8604 );
8605 assert_eq!(
8606 reloaded.connect.platforms[0].app_id.as_deref(),
8607 Some("cli_real_app_id")
8608 );
8609 assert_eq!(
8610 reloaded.connect.platforms[0].domain.as_deref(),
8611 Some("lark")
8612 );
8613 }
8614
8615 #[test]
8616 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8617 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8618 let temp = TempHome::new();
8619 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8620 std::fs::write(
8621 connect_json_path(&temp),
8622 serde_json::json!({
8623 "platforms": [
8624 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8625 ]
8626 })
8627 .to_string(),
8628 )
8629 .unwrap();
8630
8631 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8632
8633 assert_eq!(config.connect.platforms.len(), 1);
8634 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8635 assert_eq!(
8636 config.connect.platforms[0].token.as_deref(),
8637 Some("legacy-telegram-secret")
8638 );
8639 assert_eq!(
8640 config.connect.platforms[0].app_id, None,
8641 "a legacy entry with no Feishu fields deserializes them as None"
8642 );
8643 assert_eq!(config.connect.platforms[0].app_secret, None);
8644 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8645 assert_eq!(config.connect.platforms[0].domain, None);
8646 }
8647
8648 #[test]
8649 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8650 let _lock = env_lock_acquire();
8651 let temp_home = TempHome::new();
8652 temp_home.set_config_json(
8653 r#"{
8654 "provider": "openai",
8655 "providers": {
8656 "openai": {
8657 "api_key": "sk-test",
8658 "model": "gpt-4o"
8659 }
8660 }
8661}"#,
8662 );
8663
8664 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8665 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8666
8667 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8668
8669 assert_eq!(
8670 config
8671 .providers
8672 .openai
8673 .as_ref()
8674 .and_then(|c| c.model.as_deref()),
8675 Some("gpt-4o")
8676 );
8677 assert!(
8678 config.http_proxy.is_empty(),
8679 "config should keep http_proxy empty when field is omitted"
8680 );
8681 assert!(
8682 config.https_proxy.is_empty(),
8683 "config should keep https_proxy empty when field is omitted"
8684 );
8685 }
8686
8687 #[test]
8688 fn get_memory_background_model_prefers_memory_specific_override() {
8689 let mut config = Config::default();
8690 config.features.provider_model_ref = false;
8691 config.provider = "openai".to_string();
8692 config.providers.openai = Some(OpenAIConfig {
8693 api_key: "test".to_string(),
8694 api_key_encrypted: None,
8695 credential_ref: None,
8696 base_url: None,
8697 model: Some("gpt-main".to_string()),
8698 fast_model: Some("gpt-fast".to_string()),
8699 vision_model: None,
8700 reasoning_effort: None,
8701 responses_only_models: vec![],
8702 request_overrides: None,
8703 extra: BTreeMap::new(),
8704 api_key_from_env: false,
8705 });
8706 config.memory.0 = Some(MemoryConfig {
8707 background_model: Some("memory-fast".to_string()),
8708 ..MemoryConfig::default()
8709 });
8710
8711 assert_eq!(
8712 config.get_memory_background_model().as_deref(),
8713 Some("memory-fast")
8714 );
8715 }
8716
8717 #[test]
8718 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8719 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8724 api_key: api_key.to_string(),
8725 api_key_encrypted: None,
8726 credential_ref: None,
8727 base_url: None,
8728 model: None,
8729 fast_model: None,
8730 vision_model: None,
8731 reasoning_effort: None,
8732 responses_only_models: vec![],
8733 request_overrides: None,
8734 extra: BTreeMap::new(),
8735 api_key_from_env: from_env,
8736 };
8737
8738 let mut current = Config::default();
8740 current.providers.openai = Some(openai("sk-env", true));
8741 let mut merged = Config::default();
8742 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8744 let got = merged.providers.openai.as_ref().unwrap();
8745 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8746 assert!(got.api_key_from_env, "env flag restored");
8747
8748 let mut merged = Config::default();
8750 merged.providers.openai = Some(openai("sk-explicit", false));
8751 merged.preserve_env_sourced_provider_keys(¤t);
8752 assert_eq!(
8753 merged.providers.openai.as_ref().unwrap().api_key,
8754 "sk-explicit",
8755 "explicit patch key must win"
8756 );
8757
8758 let mut current_plain = Config::default();
8760 current_plain.providers.openai = Some(openai("sk-plain", false));
8761 let mut merged = Config::default();
8762 merged.providers.openai = Some(openai("", false));
8763 merged.preserve_env_sourced_provider_keys(¤t_plain);
8764 assert!(
8765 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8766 "non-env key must not be restored by this path"
8767 );
8768 }
8769
8770 #[test]
8771 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8772 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8776 api_key: api_key.to_string(),
8777 api_key_encrypted: enc.map(str::to_string),
8778 credential_ref: None,
8779 base_url: None,
8780 model: None,
8781 fast_model: None,
8782 vision_model: None,
8783 reasoning_effort: None,
8784 responses_only_models: vec![],
8785 request_overrides: None,
8786 extra: BTreeMap::new(),
8787 api_key_from_env: false,
8788 };
8789
8790 let mut config = Config::default();
8792 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8793 config
8794 .refresh_provider_api_keys_encrypted()
8795 .expect("refresh");
8796 assert_eq!(
8797 config
8798 .providers
8799 .openai
8800 .as_ref()
8801 .unwrap()
8802 .api_key_encrypted
8803 .as_deref(),
8804 Some("preexisting-ciphertext"),
8805 "existing ciphertext must be preserved when plaintext is empty"
8806 );
8807
8808 let mut config = Config::default();
8810 config.providers.openai = Some(openai("", None));
8811 config
8812 .refresh_provider_api_keys_encrypted()
8813 .expect("refresh");
8814 assert!(
8815 config
8816 .providers
8817 .openai
8818 .as_ref()
8819 .unwrap()
8820 .api_key_encrypted
8821 .is_none(),
8822 "no key + no ciphertext should stay None"
8823 );
8824
8825 let mut config = Config::default();
8827 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8828 config
8829 .refresh_provider_api_keys_encrypted()
8830 .expect("refresh");
8831 let enc = config
8832 .providers
8833 .openai
8834 .as_ref()
8835 .unwrap()
8836 .api_key_encrypted
8837 .clone()
8838 .expect("ciphertext present");
8839 assert!(
8840 !enc.is_empty() && enc != "stale-ciphertext",
8841 "plaintext re-encrypted"
8842 );
8843 }
8844
8845 #[test]
8846 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8847 let mut config = Config::default();
8853 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8854 "provider_type": "openai",
8855 "api_key": "sk-instance-live",
8856 }))
8857 .expect("valid instance");
8858 config
8859 .provider_instances
8860 .insert("work".to_string(), instance);
8861
8862 config.refresh_encrypted_secrets().expect("refresh");
8863 assert!(
8864 config.provider_instances["work"]
8865 .api_key_encrypted
8866 .is_some(),
8867 "live config must hold ciphertext after refresh"
8868 );
8869
8870 let value = serde_json::to_value(&config).expect("serialize");
8872 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8873 assert!(
8874 back.provider_instances["work"].api_key.is_empty(),
8875 "plaintext is skip_serializing"
8876 );
8877 back.hydrate_provider_instance_api_keys_from_encrypted();
8878 assert_eq!(
8879 back.provider_instances["work"].api_key, "sk-instance-live",
8880 "key must be recoverable from the round-tripped ciphertext"
8881 );
8882 }
8883
8884 #[test]
8885 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8886 let mut config = Config::default();
8887 config.features.provider_model_ref = false;
8888 config.provider = "openai".to_string();
8889 config.providers.openai = Some(OpenAIConfig {
8890 api_key: "test".to_string(),
8891 api_key_encrypted: None,
8892 credential_ref: None,
8893 base_url: None,
8894 model: Some("gpt-main".to_string()),
8895 fast_model: Some("gpt-fast".to_string()),
8896 vision_model: None,
8897 reasoning_effort: None,
8898 responses_only_models: vec![],
8899 request_overrides: None,
8900 extra: BTreeMap::new(),
8901 api_key_from_env: false,
8902 });
8903
8904 assert_eq!(
8905 config.get_memory_background_model().as_deref(),
8906 Some("gpt-fast")
8907 );
8908 }
8909
8910 #[test]
8911 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8912 let mut config = Config::default();
8913 config.features.provider_model_ref = false;
8914 config.provider = "openai".to_string();
8915 config.providers.openai = Some(OpenAIConfig {
8916 api_key: "sk-stale".to_string(),
8917 model: Some("legacy-main".to_string()),
8918 fast_model: Some("legacy-fast".to_string()),
8919 vision_model: Some("legacy-vision".to_string()),
8920 reasoning_effort: Some(ReasoningEffort::Low),
8921 ..OpenAIConfig::default()
8922 });
8923 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8924 "provider_type": "openai",
8925 "model": "instance-main",
8926 "fast_model": "instance-fast",
8927 "vision_model": "instance-vision",
8928 "reasoning_effort": "high",
8929 "enabled": true
8930 }))
8931 .unwrap();
8932 instance.api_key = "sk-instance".to_string();
8933 config
8934 .provider_instances
8935 .insert("work".to_string(), instance);
8936 config.default_provider_instance = Some("work".to_string());
8937
8938 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8939 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8940 assert_eq!(
8941 config.get_memory_background_model().as_deref(),
8942 Some("instance-fast")
8943 );
8944 assert_eq!(
8945 config.get_task_summary_model().as_deref(),
8946 Some("instance-fast")
8947 );
8948 assert_eq!(
8949 config.get_vision_model().as_deref(),
8950 Some("instance-vision")
8951 );
8952 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8953 }
8954
8955 #[test]
8956 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8957 let mut config = Config::default();
8958 config.features.provider_model_ref = true;
8959 config.provider_instances.insert(
8960 "compatibility-default".to_string(),
8961 serde_json::from_value(serde_json::json!({
8962 "provider_type": "openai",
8963 "reasoning_effort": "low",
8964 "enabled": true
8965 }))
8966 .unwrap(),
8967 );
8968 config.provider_instances.insert(
8969 "chat-provider".to_string(),
8970 serde_json::from_value(serde_json::json!({
8971 "provider_type": "anthropic",
8972 "reasoning_effort": "high",
8973 "enabled": true
8974 }))
8975 .unwrap(),
8976 );
8977 config.default_provider_instance = Some("compatibility-default".to_string());
8978 config.defaults = Some(
8979 serde_json::from_value(serde_json::json!({
8980 "chat": { "provider": "chat-provider", "model": "claude" }
8981 }))
8982 .unwrap(),
8983 );
8984
8985 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8986
8987 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8988 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8989 }
8990
8991 #[test]
8992 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8993 let _provider =
8994 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8995 let _openai_key = crate::test_support::override_runtime_env_var(
8996 "BAMBOO_OPENAI_API_KEY",
8997 Some("sk-runtime-env-instance"),
8998 );
8999 let _anthropic_key =
9000 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9001 let _gemini_key =
9002 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9003
9004 let mut config = Config::default();
9005 *config.providers_mut() = ProviderConfigs::default();
9006 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
9007 let mut extra = serde_json::Map::new();
9008 if from_environment {
9009 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
9010 }
9011 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
9012 extra.insert("enabled".to_string(), serde_json::json!(true));
9013 config.provider_instances.insert(
9014 id.to_string(),
9015 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
9016 );
9017 }
9018
9019 config.apply_runtime_env_overrides();
9020
9021 assert_eq!(
9022 config.default_provider_instance.as_deref(),
9023 Some("a-marked"),
9024 "type override selects the lexicographically first enabled instance"
9025 );
9026 assert_eq!(
9027 config.provider_instances["a-marked"].api_key,
9028 "sk-runtime-env-instance"
9029 );
9030 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
9031 assert!(
9032 config.providers().openai.is_none(),
9033 "instance-mode env hydration must not recreate a legacy alias"
9034 );
9035
9036 config.refresh_encrypted_secrets().unwrap();
9037 let serialized = serde_json::to_string(&config).unwrap();
9038 assert!(!serialized.contains("sk-runtime-env-instance"));
9039 assert!(
9040 config.provider_instances["a-marked"]
9041 .api_key_encrypted
9042 .is_none(),
9043 "runtime env plaintext must not be encrypted into ordinary config"
9044 );
9045 }
9046
9047 #[test]
9048 fn runtime_provider_override_prefers_exact_instance_id() {
9049 let _provider =
9050 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9051 let _openai_key =
9052 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9053 let _anthropic_key =
9054 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9055 let _gemini_key =
9056 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9057 let mut config = Config::default();
9058 for id in ["work", "personal"] {
9059 config.provider_instances.insert(
9060 id.to_string(),
9061 serde_json::from_value(serde_json::json!({
9062 "provider_type": "openai",
9063 "enabled": true
9064 }))
9065 .unwrap(),
9066 );
9067 }
9068
9069 config.apply_runtime_env_overrides();
9070 assert_eq!(
9071 config.default_provider_instance.as_deref(),
9072 Some("personal")
9073 );
9074 }
9075
9076 #[test]
9077 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9078 let mut config = Config::default();
9079 config.features.provider_model_ref = false;
9080 config.provider = "openai".to_string();
9081 config.providers.openai = Some(OpenAIConfig {
9082 api_key: "test".to_string(),
9083 api_key_encrypted: None,
9084 credential_ref: None,
9085 base_url: None,
9086 model: Some("gpt-main".to_string()),
9087 fast_model: None,
9088 vision_model: None,
9089 reasoning_effort: None,
9090 responses_only_models: vec![],
9091 request_overrides: None,
9092 extra: BTreeMap::new(),
9093 api_key_from_env: false,
9094 });
9095
9096 assert_eq!(
9097 config.get_memory_background_model().as_deref(),
9098 Some("gpt-main")
9099 );
9100 }
9101
9102 #[test]
9103 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9104 let legacy = serde_json::json!({
9105 "memory": MemoryConfig {
9106 background_model: Some("dream-fast".to_string()),
9107 summary_target_ratio: 0.20,
9108 summary_safe_window_percent: 80,
9109 auto_dream_enabled: true,
9110 auto_dream_interval_secs: 900,
9111 project_prompt_injection: false,
9112 relevant_recall: false,
9113 relevant_recall_rerank: true,
9114 project_first_dream: false,
9115 ledger_agenda_injection: false,
9116 ledger_gardener_enabled: false,
9117 ledger_gardener_interval_secs: 7_200,
9118 ledger_distillation_enabled: false,
9119 dream_refine_mode: true,
9120 gardener_enabled: true,
9121 gardener_interval_secs: 3_600,
9122 gardener_volume_trigger: 40,
9123 gardener_max_splits_per_run: 4,
9124 gardener_min_sections: 7,
9125 dedup_gardener_enabled: true,
9126 dedup_gardener_min_score: 0.7,
9127 dedup_gardener_max_merges_per_run: 3,
9128 memory_active_capacity: 500,
9129 capacity_max_archivals_per_run: 10,
9130 granularity_freshness_gardener_enabled: false,
9131 }
9132 });
9133 let config: Config = serde_json::from_value(legacy).unwrap();
9134
9135 let serialized = serde_json::to_value(&config).expect("config should serialize");
9136 assert!(serialized.get("memory").is_some());
9137 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9138 assert!(round_tripped
9139 .memory()
9140 .as_ref()
9141 .is_some_and(|memory| memory.dream_refine_mode));
9142 let memory = config.memory.as_ref().expect("memory config should exist");
9143 assert!(memory.auto_dream_enabled);
9144 assert!(!memory.project_prompt_injection);
9145 assert!(!memory.relevant_recall);
9146 assert!(memory.relevant_recall_rerank);
9147 assert!(!memory.project_first_dream);
9148 assert!(memory.dream_refine_mode);
9149 assert!(memory.gardener_enabled);
9150 assert_eq!(memory.gardener_interval_secs, 3_600);
9151 assert_eq!(memory.gardener_volume_trigger, 40);
9152 assert_eq!(memory.gardener_max_splits_per_run, 4);
9153 assert_eq!(memory.gardener_min_sections, 7);
9154 assert!(memory.dedup_gardener_enabled);
9155 assert_eq!(memory.dedup_gardener_min_score, 0.7);
9156 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9157 assert_eq!(memory.memory_active_capacity, 500);
9158 assert_eq!(memory.capacity_max_archivals_per_run, 10);
9159 assert!(!memory.granularity_freshness_gardener_enabled);
9160 }
9161
9162 #[test]
9164 fn memory_active_capacity_defaults_off() {
9165 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9166 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9167 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9168 let memory = parsed.memory.as_ref().unwrap();
9169 assert_eq!(memory.memory_active_capacity, 0);
9170 assert_eq!(
9171 memory.capacity_max_archivals_per_run, 50,
9172 "omitted field takes the serde default fn"
9173 );
9174 }
9175
9176 #[test]
9177 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9178 let defaults = MemoryConfig::default();
9179 assert_eq!(defaults.summary_target_ratio, 0.20);
9180 assert_eq!(defaults.summary_safe_window_percent, 80);
9181
9182 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9183 let memory = parsed.memory.as_ref().expect("memory present");
9184 assert_eq!(memory.summary_target_ratio, 0.20);
9185 assert_eq!(memory.summary_safe_window_percent, 80);
9186 }
9187
9188 #[test]
9192 fn memory_maintenance_integrators_default_on() {
9193 let defaults = MemoryConfig::default();
9194 assert!(defaults.auto_dream_enabled);
9195 assert!(defaults.gardener_enabled);
9196 assert!(defaults.dedup_gardener_enabled);
9197 assert_eq!(defaults.gardener_volume_trigger, 25);
9198
9199 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9201 let memory = parsed.memory.as_ref().expect("memory present");
9202 assert!(
9203 memory.auto_dream_enabled,
9204 "auto_dream on when field omitted"
9205 );
9206 assert!(memory.gardener_enabled, "gardener on when field omitted");
9207 assert!(
9208 memory.dedup_gardener_enabled,
9209 "dedup gardener on when field omitted"
9210 );
9211 let opted_out: Config =
9213 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9214 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9215 }
9216
9217 #[test]
9218 fn memory_config_env_overrides_prompt_flags() {
9219 let _lock = env_lock_acquire();
9220 let temp_home = TempHome::new();
9221 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9222 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9223 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9224 let _relevant_recall_rerank =
9225 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9226 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9227
9228 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9229 let memory = config
9230 .memory
9231 .as_ref()
9232 .expect("memory config should be created by env overrides");
9233 assert!(!memory.project_prompt_injection);
9234 assert!(!memory.relevant_recall);
9235 assert!(memory.relevant_recall_rerank);
9236 assert!(!memory.project_first_dream);
9237 }
9238
9239 #[test]
9240 fn provider_api_keys_injected_from_env_and_never_persisted() {
9241 let _lock = env_lock_acquire();
9242 let temp_home = TempHome::new();
9243 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9244 let _anthropic = crate::test_support::override_runtime_env_var(
9245 "BAMBOO_ANTHROPIC_API_KEY",
9246 Some("sk-ant-from-env"),
9247 );
9248 let _openai = crate::test_support::override_runtime_env_var(
9249 "BAMBOO_OPENAI_API_KEY",
9250 Some("sk-oai-from-env"),
9251 );
9252 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9253
9254 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9257 assert_eq!(
9258 config
9259 .providers
9260 .anthropic
9261 .as_ref()
9262 .expect("anthropic created from env")
9263 .api_key,
9264 "sk-ant-from-env"
9265 );
9266 assert_eq!(
9267 config
9268 .providers
9269 .openai
9270 .as_ref()
9271 .expect("openai created from env")
9272 .api_key,
9273 "sk-oai-from-env"
9274 );
9275 assert!(config.providers.gemini.is_none());
9277
9278 config
9284 .save_to_dir(temp_home.path.clone())
9285 .expect("save config");
9286 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9287 .expect("read persisted config.json");
9288 assert!(
9289 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9290 "env key must not be persisted as plaintext"
9291 );
9292 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9293 assert!(
9294 disk_json["providers"]["anthropic"]
9295 .get("api_key_encrypted")
9296 .is_none(),
9297 "env-sourced anthropic key must not be re-encrypted into config.json"
9298 );
9299 assert!(
9300 disk_json["providers"]["openai"]
9301 .get("api_key_encrypted")
9302 .is_none(),
9303 "env-sourced openai key must not be re-encrypted into config.json"
9304 );
9305
9306 drop(_anthropic);
9309 drop(_openai);
9310 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9311 assert!(reloaded
9312 .providers
9313 .anthropic
9314 .as_ref()
9315 .map(|a| a.api_key.is_empty())
9316 .unwrap_or(true));
9317 }
9318
9319 #[test]
9320 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9321 let _lock = env_lock_acquire();
9322 let temp_home = TempHome::new();
9323 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9324 let target = temp_home.path.join("workspace-default");
9325 std::fs::create_dir_all(&target).expect("default work area dir should exist");
9326
9327 let mut config = Config::default();
9328 config.default_work_area.replace(DefaultWorkAreaConfig {
9329 path: Some("~/workspace-default".to_string()),
9330 });
9331
9332 assert_eq!(config.get_default_work_area_path(), Some(target));
9333 }
9334
9335 #[test]
9336 fn get_default_work_area_path_returns_none_for_missing_directory() {
9337 let _lock = env_lock_acquire();
9338 let temp_home = TempHome::new();
9339 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9340
9341 let mut config = Config::default();
9342 config.default_work_area.replace(DefaultWorkAreaConfig {
9343 path: Some("~/missing-default-work-area".to_string()),
9344 });
9345
9346 assert!(config.get_default_work_area_path().is_none());
9347 }
9348
9349 #[test]
9350 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9351 let mut config = Config::default();
9352 config.tools.disabled = vec![
9353 " read_file ".to_string(),
9354 "".to_string(),
9355 "read_file".to_string(),
9356 "bash".to_string(),
9357 "default::getCurrentDir".to_string(),
9358 "default::applyPatch".to_string(),
9359 "default::custom_tool".to_string(),
9360 "mcp__alpha__inspect".to_string(),
9361 ];
9362
9363 config.normalize_tool_settings();
9364
9365 assert_eq!(
9366 config.tools.disabled,
9367 vec![
9368 "bash",
9369 "default::applyPatch",
9370 "default::custom_tool",
9371 "default::getCurrentDir",
9372 "mcp__alpha__inspect",
9373 "read_file"
9374 ]
9375 );
9376 }
9377
9378 #[test]
9379 fn config_load_preserves_disabled_references_for_catalog_resolution() {
9380 let _lock = env_lock_acquire();
9381 let temp_home = TempHome::new();
9382 temp_home.set_config_json(
9383 r#"{
9384 "tools": {
9385 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9386 }
9387}"#,
9388 );
9389
9390 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9391 assert_eq!(
9392 config.tools.disabled,
9393 vec!["bash", "default::getCurrentDir", "read_file"]
9394 );
9395 assert!(config.disabled_tool_references().contains("bash"));
9396 assert!(config.disabled_tool_references().contains("read_file"));
9397 assert!(config
9398 .disabled_tool_references()
9399 .contains("default::getCurrentDir"));
9400 assert_eq!(
9401 config.disabled_tool_names(),
9402 BTreeSet::from([
9403 "Bash".to_string(),
9404 "GetCurrentDir".to_string(),
9405 "Read".to_string()
9406 ])
9407 );
9408 }
9409
9410 #[test]
9411 fn normalize_skill_settings_trims_dedupes_and_sorts() {
9412 let mut config = Config::default();
9413 config.skills.disabled = vec![
9414 " pdf ".to_string(),
9415 "".to_string(),
9416 "pdf".to_string(),
9417 "skill-creator".to_string(),
9418 ];
9419
9420 config.normalize_skill_settings();
9421
9422 assert_eq!(
9423 config.skills.disabled,
9424 vec!["pdf".to_string(), "skill-creator".to_string()]
9425 );
9426 }
9427
9428 #[test]
9429 fn config_load_reads_disabled_skills_as_normalized_ids() {
9430 let _lock = env_lock_acquire();
9431 let temp_home = TempHome::new();
9432 temp_home.set_config_json(
9433 r#"{
9434 "skills": {
9435 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9436 }
9437}"#,
9438 );
9439
9440 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9441 assert_eq!(
9442 config.skills.disabled,
9443 vec!["pdf".to_string(), "skill-creator".to_string()]
9444 );
9445 assert!(config.disabled_skill_ids().contains("pdf"));
9446 assert!(config.disabled_skill_ids().contains("skill-creator"));
9447 }
9448
9449 #[test]
9450 fn test_server_config_defaults() {
9451 let _lock = env_lock_acquire();
9452 let temp_home = TempHome::new();
9453
9454 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9455 assert_eq!(config.server.port, 9562);
9456 assert_eq!(config.server.bind, "127.0.0.1");
9457 assert_eq!(config.server.workers, 10);
9458 assert!(config.server.static_dir.is_none());
9459 }
9460
9461 #[test]
9462 fn test_server_addr() {
9463 let mut config = Config::default();
9464 config.server.port = 9000;
9465 config.server.bind = "0.0.0.0".to_string();
9466 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9467 }
9468
9469 #[test]
9470 fn test_env_var_overrides() {
9471 let _lock = env_lock_acquire();
9472 let temp_home = TempHome::new();
9473
9474 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9475 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9476 let _provider =
9477 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9478
9479 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9480 assert_eq!(config.server.port, 9999);
9481 assert_eq!(config.server.bind, "192.168.1.1");
9482 assert_eq!(config.provider, "openai");
9483 }
9484
9485 #[test]
9486 fn test_config_save_and_load() {
9487 let _lock = env_lock_acquire();
9488 let temp_home = TempHome::new();
9489
9490 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9491 config.server.port = 9000;
9492 config.server.bind = "0.0.0.0".to_string();
9493 config.provider = "anthropic".to_string();
9494
9495 config
9497 .save_to_dir(temp_home.path.clone())
9498 .expect("Failed to save config");
9499
9500 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9502
9503 assert_eq!(loaded.server.port, 9000);
9505 assert_eq!(loaded.server.bind, "0.0.0.0");
9506 assert_eq!(loaded.provider, "anthropic");
9507 }
9508
9509 #[test]
9510 fn modular_root_and_public_serde_preserve_legacy_shape() {
9511 let _lock = env_lock_acquire();
9512 let temp_home = TempHome::new();
9513 let input = serde_json::json!({
9514 "http_proxy": "http://proxy.example",
9515 "provider": "openai",
9516 "mcp": { "servers": [] },
9517 "future_extension": { "enabled": true },
9518 "memory": { "background_model": "memory-model" },
9519 "subagents": { "max_concurrent": 7 },
9520 "providers": { "openai": { "model": "chat-model" } }
9521 });
9522
9523 let config: Config = serde_json::from_value(input).unwrap();
9524 let public = serde_json::to_value(&config).unwrap();
9525 assert_eq!(public["http_proxy"], "http://proxy.example");
9526 assert!(public.get("mcp").is_none());
9527 assert!(public.get("mcpServers").is_some());
9528 assert_eq!(public["future_extension"]["enabled"], true);
9529 assert_eq!(public["memory"]["background_model"], "memory-model");
9530 assert_eq!(public["subagents"]["max_concurrent"], 7);
9531 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9532
9533 let round_tripped: Config = serde_json::from_value(public).unwrap();
9534 assert_eq!(
9535 round_tripped
9536 .memory()
9537 .as_ref()
9538 .unwrap()
9539 .background_model
9540 .as_deref(),
9541 Some("memory-model")
9542 );
9543 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9544 assert_eq!(
9545 round_tripped
9546 .providers()
9547 .openai
9548 .as_ref()
9549 .unwrap()
9550 .model
9551 .as_deref(),
9552 Some("chat-model")
9553 );
9554 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9555
9556 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9557 let persisted: Value =
9558 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9559 .unwrap();
9560 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9561 assert!(persisted.get("mcpServers").is_some());
9562 assert_eq!(persisted["future_extension"]["enabled"], true);
9563 assert!(persisted.get("memory").is_none());
9564 assert!(persisted.get("subagents").is_none());
9565 assert!(persisted.get("providers").is_none());
9566 for internal_section_name in [
9567 "network",
9568 "provider_routing",
9569 "model_behavior",
9570 "tooling",
9571 "workspace",
9572 "execution",
9573 "integrations",
9574 "plugin_security",
9575 ] {
9576 assert!(persisted.get(internal_section_name).is_none());
9577 }
9578 }
9579
9580 #[test]
9581 fn config_decrypts_proxy_auth_from_encrypted_field() {
9582 let _lock = env_lock_acquire();
9583 let temp_home = TempHome::new();
9584
9585 let key_guard = crate::encryption::set_test_encryption_key([
9587 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9588 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9589 0x1c, 0x1d, 0x1e, 0x1f,
9590 ]);
9591
9592 let auth = ProxyAuth {
9593 username: "user".to_string(),
9594 password: "pass".to_string(),
9595 };
9596 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9597 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9598
9599 temp_home.set_config_json(&format!(
9600 r#"{{
9601 "http_proxy": "http://proxy.example.com:8080",
9602 "proxy_auth_encrypted": "{encrypted}"
9603}}"#
9604 ));
9605 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9606 let loaded_auth = config
9607 .proxy_auth
9608 .as_ref()
9609 .expect("proxy auth should be hydrated");
9610 assert_eq!(loaded_auth.username, "user");
9611 assert_eq!(loaded_auth.password, "pass");
9612 drop(key_guard);
9613 }
9614
9615 #[test]
9616 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9617 let _lock = env_lock_acquire();
9618 let temp_home = TempHome::new();
9619
9620 let key_guard = crate::encryption::set_test_encryption_key([
9622 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9623 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9624 0x1c, 0x1d, 0x1e, 0x1f,
9625 ]);
9626
9627 let auth = ProxyAuth {
9628 username: "user".to_string(),
9629 password: "pass".to_string(),
9630 };
9631 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9632 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9633
9634 temp_home.set_config_json(&format!(
9636 r#"{{
9637 "http_proxy": "http://proxy.example.com:8080",
9638 "http_proxy_auth_encrypted": "{encrypted}",
9639 "https_proxy_auth_encrypted": "{encrypted}"
9640}}"#
9641 ));
9642
9643 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9644 let loaded_auth = config
9645 .proxy_auth
9646 .as_ref()
9647 .expect("proxy auth should be hydrated");
9648 assert_eq!(loaded_auth.username, "user");
9649 assert_eq!(loaded_auth.password, "pass");
9650 drop(key_guard);
9651 }
9652
9653 #[test]
9654 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9655 let _lock = env_lock_acquire();
9656 let temp_home = TempHome::new();
9657
9658 let key_guard = crate::encryption::set_test_encryption_key([
9660 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9661 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9662 0x1c, 0x1d, 0x1e, 0x1f,
9663 ]);
9664
9665 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9666 config.proxy_auth = Some(ProxyAuth {
9667 username: "user".to_string(),
9668 password: "pass".to_string(),
9669 });
9670 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9671 assert!(error
9672 .to_string()
9673 .contains("isolated credential transaction"));
9674 let path = temp_home.path.join("config.json");
9675 assert!(
9676 !path.exists(),
9677 "a rejected unisolated secret must not create config.json"
9678 );
9679 drop(key_guard);
9680 }
9681
9682 #[test]
9683 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9684 let _lock = env_lock_acquire();
9685 let temp_home = TempHome::new();
9686 let mut config = Config::default();
9687 config.env_vars.push(EnvVarEntry {
9688 name: "TOKEN".to_string(),
9689 value: String::new(),
9690 secret: true,
9691 value_encrypted: None,
9692 credential_ref: None,
9693 configured: true,
9694 description: None,
9695 });
9696 let path = temp_home.path.join("config.json");
9697
9698 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9699 assert!(error
9700 .to_string()
9701 .contains("isolated credential transaction"));
9702 assert!(
9703 !path.exists(),
9704 "a rejected dangling ref must not create config.json"
9705 );
9706
9707 let original = br#"{"preserve":"original"}"#;
9708 std::fs::write(&path, original).unwrap();
9709 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9710 assert_eq!(
9711 std::fs::read(&path).unwrap(),
9712 original,
9713 "a rejected dangling ref must not modify an existing config.json"
9714 );
9715 }
9716
9717 #[test]
9718 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9719 let _lock = env_lock_acquire();
9720 let temp_home = TempHome::new();
9721
9722 let key_guard = crate::encryption::set_test_encryption_key([
9724 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9725 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9726 0x1c, 0x1d, 0x1e, 0x1f,
9727 ]);
9728
9729 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9730 crate::CredentialStore::open(&temp_home.path)
9731 .replace(
9732 reference.clone(),
9733 "sk-test-provider-key",
9734 crate::CredentialSource::User,
9735 0,
9736 )
9737 .unwrap();
9738 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9739 config.provider = "openai".to_string();
9740 config.providers.openai = Some(OpenAIConfig {
9741 api_key: "sk-test-provider-key".to_string(),
9742 api_key_encrypted: None,
9743 credential_ref: Some(reference),
9744 base_url: None,
9745 model: None,
9746 fast_model: None,
9747 vision_model: None,
9748 reasoning_effort: None,
9749 responses_only_models: vec![],
9750 request_overrides: None,
9751 extra: Default::default(),
9752 api_key_from_env: false,
9753 });
9754
9755 config
9756 .save_to_dir(temp_home.path.clone())
9757 .expect("save should persist provider reference");
9758
9759 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9760 .expect("read providers.json");
9761 assert!(
9762 !content.contains("api_key_encrypted"),
9763 "providers.json must not store provider ciphertext"
9764 );
9765 assert!(
9766 !content.contains("\"api_key\""),
9767 "providers.json should not store plaintext provider keys"
9768 );
9769
9770 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9771 let openai = loaded
9772 .providers
9773 .openai
9774 .as_ref()
9775 .expect("openai config should be present");
9776 assert_eq!(openai.api_key, "sk-test-provider-key");
9777
9778 drop(key_guard);
9779 }
9780
9781 #[test]
9782 fn config_save_persists_mcp_servers_in_mainstream_format() {
9783 let _lock = env_lock_acquire();
9784 let temp_home = TempHome::new();
9785
9786 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9787
9788 let mut env = std::collections::HashMap::new();
9789 env.insert("TOKEN".to_string(), "supersecret".to_string());
9790
9791 config.mcp.servers = vec![
9792 bamboo_domain::mcp_config::McpServerConfig {
9793 id: "stdio-secret".to_string(),
9794 name: None,
9795 enabled: true,
9796 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9797 bamboo_domain::mcp_config::StdioConfig {
9798 command: "echo".to_string(),
9799 args: vec![],
9800 cwd: None,
9801 env,
9802 env_encrypted: std::collections::HashMap::new(),
9803 env_credential_refs: std::collections::HashMap::new(),
9804 startup_timeout_ms: 5000,
9805 },
9806 ),
9807 request_timeout_ms: 5000,
9808 healthcheck_interval_ms: 1000,
9809 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9810 allowed_tools: vec![],
9811 denied_tools: vec![],
9812 },
9813 bamboo_domain::mcp_config::McpServerConfig {
9814 id: "http-secret".to_string(),
9815 name: None,
9816 enabled: true,
9817 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9818 bamboo_domain::mcp_config::StreamableHttpConfig {
9819 url: "http://localhost:8080/http".to_string(),
9820 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9821 name: "Authorization".to_string(),
9822 value: "Bearer token123".to_string(),
9823 value_encrypted: None,
9824 credential_ref: None,
9825 }],
9826 connect_timeout_ms: 5000,
9827 },
9828 ),
9829 request_timeout_ms: 5000,
9830 healthcheck_interval_ms: 1000,
9831 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9832 allowed_tools: vec![],
9833 denied_tools: vec![],
9834 },
9835 ];
9836
9837 config
9838 .save_to_dir(temp_home.path.clone())
9839 .expect("save should persist MCP servers");
9840
9841 let content =
9842 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9843 assert!(
9844 content.contains("\"mcpServers\""),
9845 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9846 );
9847 assert!(
9848 content.contains("supersecret"),
9849 "config.json should persist MCP stdio env in mainstream format"
9850 );
9851 assert!(
9852 content.contains("Bearer token123"),
9853 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9854 );
9855 assert!(
9856 !content.contains("\"env_encrypted\""),
9857 "config.json should not persist legacy env_encrypted fields"
9858 );
9859 assert!(
9860 !content.contains("\"value_encrypted\""),
9861 "config.json should not persist legacy value_encrypted fields"
9862 );
9863
9864 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9865 let stdio = loaded
9866 .mcp
9867 .servers
9868 .iter()
9869 .find(|s| s.id == "stdio-secret")
9870 .expect("stdio server should exist");
9871 match &stdio.transport {
9872 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9873 assert_eq!(
9874 stdio.env.get("TOKEN").map(|s| s.as_str()),
9875 Some("supersecret")
9876 );
9877 }
9878 _ => panic!("Expected stdio transport"),
9879 }
9880
9881 let http = loaded
9882 .mcp
9883 .servers
9884 .iter()
9885 .find(|s| s.id == "http-secret")
9886 .expect("http server should exist");
9887 match &http.transport {
9888 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9889 assert_eq!(http.headers[0].value, "Bearer token123");
9890 }
9891 _ => panic!("Expected Streamable HTTP transport"),
9892 }
9893 }
9894
9895 #[test]
9896 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9897 let _lock = env_lock_acquire();
9898 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9899 let temp_home = TempHome::new();
9900 let env_plaintext = "mcp-root-env-plaintext-597";
9901 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9902 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9903 std::fs::write(
9904 temp_home.path.join("config.json"),
9905 serde_json::to_vec_pretty(&serde_json::json!({
9906 "features": {"provider_model_ref": true},
9907 "mcpServers": {
9908 "stdio-root": {
9909 "command": "unused-disabled-command",
9910 "env": {"TOKEN": env_plaintext},
9911 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9912 "env_credential_refs": {
9913 "TOKEN": "mcp.stdio-root.env_TOKEN"
9914 }
9915 },
9916 "http-root": {
9917 "url": "https://example.test/mcp",
9918 "transport_kind": "streamable_http",
9919 "headers": {"Authorization": header_plaintext},
9920 "header_credential_refs": {
9921 "Authorization": "mcp.http-root.header_Authorization"
9922 }
9923 }
9924 }
9925 }))
9926 .unwrap(),
9927 )
9928 .unwrap();
9929 std::fs::write(
9930 temp_home.path.join("mcp.json"),
9931 serde_json::to_vec_pretty(&serde_json::json!({
9932 "schema_version": 1,
9933 "revision": 7,
9934 "data": {
9935 "stdio-root": {
9936 "command": "unused-disabled-command",
9937 "enabled": false,
9938 "env_encrypted": {"TOKEN": legacy_ciphertext},
9939 "request_timeout_ms": 100,
9940 "healthcheck_interval_ms": 100
9941 },
9942 "http-root": {
9943 "url": "https://example.test/mcp",
9944 "transport_kind": "streamable_http",
9945 "enabled": false,
9946 "headers": {"Authorization": header_plaintext},
9947 "request_timeout_ms": 100,
9948 "healthcheck_interval_ms": 100
9949 }
9950 }
9951 }))
9952 .unwrap(),
9953 )
9954 .unwrap();
9955
9956 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9957 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9958 temp_home.path.join("mcp.json"),
9959 1,
9960 )
9961 .load()
9962 .unwrap()
9963 .unwrap();
9964 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9965 config.mcp = stored.data;
9966 config
9967 .hydrate_mcp_credentials_from_store(&temp_home.path)
9968 .unwrap();
9969
9970 let public = serde_json::to_value(&config.mcp).unwrap();
9971 let rendered_public = public.to_string();
9972 assert!(rendered_public.contains(env_plaintext));
9973 assert!(rendered_public.contains(header_plaintext));
9974 let compatible: bamboo_domain::mcp_config::McpConfig =
9975 serde_json::from_value(public).unwrap();
9976 assert_eq!(compatible.servers.len(), 2);
9977
9978 config.features.provider_model_ref = !config.features.provider_model_ref;
9979 config.save_to_dir(temp_home.path.clone()).unwrap();
9980 config.features.provider_model_ref = !config.features.provider_model_ref;
9981 config.save_to_dir(temp_home.path.clone()).unwrap();
9982
9983 for entry in std::fs::read_dir(&temp_home.path)
9984 .unwrap()
9985 .filter_map(Result::ok)
9986 {
9987 let name = entry.file_name().to_string_lossy().to_string();
9988 if name == "credentials.json" || entry.path().is_dir() {
9989 continue;
9990 }
9991 if name == "config.json"
9992 || name == "mcp.json"
9993 || name.contains(".bak")
9994 || name.starts_with("config-credential-migration")
9995 {
9996 let bytes = std::fs::read(entry.path()).unwrap();
9997 let content = String::from_utf8_lossy(&bytes);
9998 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9999 assert!(
10000 !content.contains(header_plaintext),
10001 "secret leaked to {name}"
10002 );
10003 assert!(
10004 !content.contains(&legacy_ciphertext),
10005 "legacy ciphertext leaked to {name}"
10006 );
10007 }
10008 }
10009 }
10010
10011 #[test]
10014 fn env_vars_as_map_includes_only_non_empty_values() {
10015 let mut config = Config::default();
10016 config.env_vars.extend([
10017 EnvVarEntry {
10018 name: "A".to_string(),
10019 value: "val_a".to_string(),
10020 secret: false,
10021 value_encrypted: None,
10022 credential_ref: None,
10023 configured: true,
10024 description: None,
10025 },
10026 EnvVarEntry {
10027 name: "B".to_string(),
10028 value: "".to_string(), secret: true,
10030 value_encrypted: None,
10031 credential_ref: None,
10032 configured: false,
10033 description: None,
10034 },
10035 EnvVarEntry {
10036 name: "C".to_string(),
10037 value: " ".to_string(), secret: false,
10039 value_encrypted: None,
10040 credential_ref: None,
10041 configured: true,
10042 description: None,
10043 },
10044 EnvVarEntry {
10045 name: "D".to_string(),
10046 value: "val_d".to_string(),
10047 secret: true,
10048 value_encrypted: Some("enc".to_string()),
10049 credential_ref: None,
10050 configured: true,
10051 description: Some("desc".to_string()),
10052 },
10053 ]);
10054
10055 let map = config.env_vars_as_map();
10056 assert_eq!(map.len(), 2);
10057 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10058 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10059 assert!(!map.contains_key("B"));
10060 assert!(!map.contains_key("C"));
10061 }
10062
10063 #[test]
10064 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10065 let mut config = Config::default();
10066 config.env_vars.extend([
10067 EnvVarEntry {
10068 name: "PLAIN".to_string(),
10069 value: "visible".to_string(),
10070 secret: false,
10071 value_encrypted: None,
10072 credential_ref: None,
10073 configured: true,
10074 description: None,
10075 },
10076 EnvVarEntry {
10077 name: "SECRET".to_string(),
10078 value: "hidden_value".to_string(),
10079 secret: true,
10080 value_encrypted: Some("enc_data".to_string()),
10081 credential_ref: None,
10082 configured: true,
10083 description: None,
10084 },
10085 ]);
10086
10087 config.sanitize_env_vars_for_disk();
10088
10089 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
10092
10093 #[test]
10094 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10095 let mut config = Config::default();
10096 config.env_vars.extend([
10097 EnvVarEntry {
10098 name: "OPEN".to_string(),
10099 value: "val".to_string(),
10100 secret: false,
10101 value_encrypted: None,
10102 credential_ref: None,
10103 configured: true,
10104 description: None,
10105 },
10106 EnvVarEntry {
10107 name: "HIDDEN".to_string(),
10108 value: "real_secret".to_string(),
10109 secret: true,
10110 value_encrypted: Some("enc".to_string()),
10111 credential_ref: None,
10112 configured: true,
10113 description: None,
10114 },
10115 ]);
10116
10117 config.sanitize_env_vars_for_disk();
10118
10119 assert_eq!(config.env_vars[0].value, "val");
10121 assert_eq!(config.env_vars[1].value, "");
10123 assert!(config.env_vars[1].value_encrypted.is_none());
10124 }
10125
10126 #[test]
10127 fn legacy_env_ciphertext_is_read_but_never_serialized() {
10128 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10129 let mut config: Config = serde_json::from_value(serde_json::json!({
10130 "env_vars": [{
10131 "name": "TOKEN",
10132 "secret": true,
10133 "value_encrypted": ciphertext
10134 }]
10135 }))
10136 .unwrap();
10137 config.hydrate_env_vars_from_encrypted();
10138 assert_eq!(config.env_vars[0].value, "my-secret-token");
10139 let serialized = serde_json::to_value(&config).unwrap();
10140 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10141 }
10142
10143 #[test]
10144 fn configured_env_ref_missing_from_store_fails_closed() {
10145 let dir = tempfile::tempdir().unwrap();
10146 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10147 let mut config = Config::default();
10148 config.env_vars.push(EnvVarEntry {
10149 name: "TOKEN".to_string(),
10150 value: String::new(),
10151 secret: true,
10152 value_encrypted: None,
10153 credential_ref: Some(reference),
10154 configured: true,
10155 description: None,
10156 });
10157 let error = config
10158 .hydrate_env_var_credentials_from_store(dir.path())
10159 .unwrap_err();
10160 assert!(error
10161 .to_string()
10162 .contains("referenced env credential is unavailable"));
10163 assert!(config.env_vars[0].configured);
10164 assert!(config.env_vars[0].value.is_empty());
10165 }
10166
10167 #[test]
10168 fn configured_notification_ref_missing_from_store_fails_closed() {
10169 let dir = tempfile::tempdir().unwrap();
10170 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10171 let mut config = Config::default();
10172 config.notifications.ntfy.credential_ref = Some(reference);
10173 config.notifications.ntfy.configured = true;
10174
10175 let error = config
10176 .hydrate_notification_credentials_from_store(dir.path())
10177 .unwrap_err();
10178
10179 assert!(error
10180 .to_string()
10181 .contains("referenced ntfy credential is unavailable"));
10182 assert!(config.notifications.ntfy.configured);
10183 assert!(config.notifications.ntfy.token.is_none());
10184 }
10185
10186 #[test]
10187 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10188 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10189 let dir = tempfile::tempdir().unwrap();
10190 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10191 crate::CredentialStore::open(dir.path())
10192 .replace(
10193 reference.clone(),
10194 "exclusive-notification-secret",
10195 crate::CredentialSource::User,
10196 0,
10197 )
10198 .unwrap();
10199
10200 let mut config = Config::default();
10201 config.notifications.ntfy.credential_ref = Some(reference.clone());
10202 config.notifications.ntfy.configured = true;
10203 config.proxy_auth_credential_ref = Some(reference.clone());
10204 let error = config
10205 .hydrate_notification_credentials_from_store(dir.path())
10206 .unwrap_err();
10207 let rendered = error.to_string();
10208 assert!(rendered
10209 .contains("notification credential reference is shared by another config consumer"));
10210 assert!(!rendered.contains(reference.as_str()));
10211 assert!(config.notifications.ntfy.token.is_none());
10212
10213 config.proxy_auth_credential_ref = None;
10214 config
10215 .hydrate_notification_credentials_from_store(dir.path())
10216 .unwrap();
10217 assert_eq!(
10218 config.notifications.ntfy.token.as_deref(),
10219 Some("exclusive-notification-secret")
10220 );
10221 }
10222
10223 #[test]
10224 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10225 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10226 let dir = tempfile::tempdir().unwrap();
10227 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10228 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10229 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10230 "notifications": {
10231 "ntfy": { "enabled": true, "token_encrypted": ntfy },
10232 "bark": { "enabled": true, "device_key_encrypted": bark }
10233 }
10234 }))
10235 .unwrap();
10236 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10237 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10238
10239 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10240
10241 assert!(loaded.notifications.ntfy.token.is_none());
10242 assert!(loaded.notifications.bark.device_key.is_none());
10243 assert_eq!(
10244 std::fs::read(dir.path().join("config.json")).unwrap(),
10245 bytes
10246 );
10247 }
10248
10249 #[test]
10250 fn publish_and_current_env_vars_round_trip() {
10251 let _lock = crate::test_support::env_cache_lock_acquire();
10261 let mut config = Config::default();
10262 config.env_vars.extend([EnvVarEntry {
10263 name: "TEST_PUBLISH".to_string(),
10264 value: "pub_value".to_string(),
10265 secret: false,
10266 value_encrypted: None,
10267 credential_ref: None,
10268 configured: true,
10269 description: None,
10270 }]);
10271
10272 config.publish_env_vars();
10273 assert_eq!(
10274 Config::current_env_vars()
10275 .get("TEST_PUBLISH")
10276 .map(String::as_str),
10277 Some("pub_value")
10278 );
10279 }
10280
10281 #[test]
10282 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10283 let mut config = Config::default();
10284 config.subagents.broker = Some(BrokerClientConfig {
10285 endpoint: "ws://127.0.0.1:9600".to_string(),
10286 token: "super-secret-token".to_string(),
10287 token_encrypted: None,
10288 credential_ref: None,
10289 configured: false,
10290 });
10291
10292 config.refresh_broker_token_encrypted().unwrap();
10294 config.sanitize_broker_token_for_disk();
10295 let broker = config.subagents.broker.as_ref().unwrap();
10296 assert!(broker.token.is_empty(), "plaintext cleared for disk");
10297 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10298 assert_ne!(
10299 broker.token_encrypted.as_deref(),
10300 Some("super-secret-token")
10301 );
10302
10303 config.hydrate_broker_token_from_encrypted();
10305 assert_eq!(
10306 config.subagents.broker.as_ref().unwrap().token,
10307 "super-secret-token"
10308 );
10309 }
10310
10311 #[test]
10312 fn broker_token_empty_refresh_preserves_ciphertext() {
10313 let mut config = Config::default();
10315 config.subagents.broker = Some(BrokerClientConfig {
10316 endpoint: "ws://h:9600".to_string(),
10317 token: String::new(),
10318 token_encrypted: Some("existing-cipher".to_string()),
10319 credential_ref: None,
10320 configured: false,
10321 });
10322 config.refresh_broker_token_encrypted().unwrap();
10323 assert_eq!(
10324 config
10325 .subagents
10326 .broker
10327 .as_ref()
10328 .unwrap()
10329 .token_encrypted
10330 .as_deref(),
10331 Some("existing-cipher"),
10332 );
10333 }
10334
10335 #[test]
10336 fn notifications_config_defaults_when_key_missing() {
10337 let config: Config = serde_json::from_str("{}").expect("empty object parses");
10340 assert_eq!(config.notifications, NotificationsConfig::default());
10341 assert_eq!(config.notifications.desktop.enabled, None);
10342 assert!(!config.notifications.ntfy.enabled);
10343 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10344 assert_eq!(config.notifications.ntfy.token, None);
10345 assert!(!config.notifications.bark.enabled);
10346 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10347 assert_eq!(config.notifications.bark.device_key, None);
10348 }
10349
10350 #[test]
10351 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10352 let mut config = Config::default();
10353 config.notifications.ntfy = NtfyChannelConfig {
10354 enabled: true,
10355 base_url: "https://ntfy.sh".to_string(),
10356 topic: "bamboo-alerts".to_string(),
10357 token: Some("tk_super_secret".to_string()),
10358 token_encrypted: None,
10359 credential_ref: None,
10360 configured: false,
10361 };
10362
10363 config.refresh_notifications_encrypted().unwrap();
10366 assert!(config.notifications.ntfy.token_encrypted.is_some());
10367 assert_ne!(
10368 config.notifications.ntfy.token_encrypted.as_deref(),
10369 Some("tk_super_secret")
10370 );
10371
10372 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10374 assert!(
10375 !json.contains("tk_super_secret"),
10376 "plaintext token must never be serialized"
10377 );
10378 assert!(!json.contains("token_encrypted"));
10379
10380 config.notifications.ntfy.token = None;
10382 config.hydrate_notifications_from_encrypted();
10383 assert_eq!(
10384 config.notifications.ntfy.token.as_deref(),
10385 Some("tk_super_secret")
10386 );
10387 }
10388
10389 #[test]
10390 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10391 let mut config = Config::default();
10392 config.notifications.bark = BarkChannelConfig {
10393 enabled: true,
10394 base_url: "https://api.day.app".to_string(),
10395 device_key: Some("dk_super_secret".to_string()),
10396 device_key_encrypted: None,
10397 credential_ref: None,
10398 configured: false,
10399 };
10400
10401 config.refresh_notifications_encrypted().unwrap();
10402 assert!(config.notifications.bark.device_key_encrypted.is_some());
10403 assert_ne!(
10404 config.notifications.bark.device_key_encrypted.as_deref(),
10405 Some("dk_super_secret")
10406 );
10407
10408 let json = serde_json::to_string(&config.notifications.bark).unwrap();
10409 assert!(
10410 !json.contains("dk_super_secret"),
10411 "plaintext device key must never be serialized"
10412 );
10413 assert!(!json.contains("device_key_encrypted"));
10414
10415 config.notifications.bark.device_key = None;
10416 config.hydrate_notifications_from_encrypted();
10417 assert_eq!(
10418 config.notifications.bark.device_key.as_deref(),
10419 Some("dk_super_secret")
10420 );
10421 }
10422
10423 #[test]
10424 fn notification_secrets_empty_refresh_preserves_ciphertext() {
10425 let mut config = Config::default();
10428 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10429 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10430
10431 config.refresh_notifications_encrypted().unwrap();
10432
10433 assert_eq!(
10434 config.notifications.ntfy.token_encrypted.as_deref(),
10435 Some("existing-ntfy-cipher")
10436 );
10437 assert_eq!(
10438 config.notifications.bark.device_key_encrypted.as_deref(),
10439 Some("existing-bark-cipher")
10440 );
10441 }
10442
10443 #[test]
10444 fn hydrate_skips_non_secret_entries() {
10445 let mut config = Config::default();
10446 config.env_vars.extend([EnvVarEntry {
10447 name: "PLAIN".to_string(),
10448 value: "original".to_string(),
10449 secret: false,
10450 value_encrypted: Some("should-be-ignored".to_string()),
10451 credential_ref: None,
10452 configured: true,
10453 description: None,
10454 }]);
10455
10456 config.hydrate_env_vars_from_encrypted();
10457 assert_eq!(config.env_vars[0].value, "original");
10459 }
10460
10461 #[test]
10462 fn default_config_has_empty_env_vars() {
10463 assert!(Config::default().env_vars.is_empty());
10468 }
10469
10470 #[test]
10471 fn serde_round_trip_with_env_vars() {
10472 let mut config = Config::default();
10473 config.env_vars.extend([
10474 EnvVarEntry {
10475 name: "KEY1".to_string(),
10476 value: "val1".to_string(),
10477 secret: false,
10478 value_encrypted: None,
10479 credential_ref: None,
10480 configured: true,
10481 description: Some("First key".to_string()),
10482 },
10483 EnvVarEntry {
10484 name: "KEY2".to_string(),
10485 value: "".to_string(), secret: true,
10487 value_encrypted: Some("enc123".to_string()),
10488 credential_ref: None,
10489 configured: true,
10490 description: None,
10491 },
10492 ]);
10493
10494 let json = serde_json::to_string(&config).unwrap();
10495 let restored: Config = serde_json::from_str(&json).unwrap();
10496
10497 assert_eq!(restored.env_vars.len(), 2);
10498 assert_eq!(restored.env_vars[0].name, "KEY1");
10499 assert_eq!(restored.env_vars[0].value, "val1");
10500 assert!(!restored.env_vars[0].secret);
10501 assert_eq!(restored.env_vars[1].name, "KEY2");
10502 assert!(restored.env_vars[1].secret);
10503 assert!(restored.env_vars[1].value_encrypted.is_none());
10504 }
10505
10506 #[test]
10509 #[allow(clippy::field_reassign_with_default)]
10511 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10512 let mut config = Config::default();
10513 config.provider = "openai".to_string();
10514 config.providers.openai = Some(OpenAIConfig {
10515 api_key: "test".to_string(),
10516 api_key_encrypted: None,
10517 credential_ref: None,
10518 base_url: None,
10519 model: Some("legacy-gpt-4o".to_string()),
10520 fast_model: None,
10521 vision_model: None,
10522 reasoning_effort: None,
10523 responses_only_models: vec![],
10524 request_overrides: None,
10525 extra: Default::default(),
10526 api_key_from_env: false,
10527 });
10528 config.features.provider_model_ref = true;
10529 config.defaults = Some(DefaultsConfig {
10530 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10531 fast: None,
10532 task_summary: None,
10533 vision: None,
10534 memory_background: None,
10535 planning: None,
10536 search: None,
10537 code_review: None,
10538 work_review: None,
10539 sub_agent: None,
10540 subagent_models: Default::default(),
10541 });
10542
10543 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10544 }
10545
10546 #[test]
10547 #[allow(clippy::field_reassign_with_default)]
10549 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10550 let mut config = Config::default();
10551 config.provider = "openai".to_string();
10552 config.providers.openai = Some(OpenAIConfig {
10553 api_key: "test".to_string(),
10554 api_key_encrypted: None,
10555 credential_ref: None,
10556 base_url: None,
10557 model: Some("legacy-gpt-4o".to_string()),
10558 fast_model: None,
10559 vision_model: None,
10560 reasoning_effort: None,
10561 responses_only_models: vec![],
10562 request_overrides: None,
10563 extra: Default::default(),
10564 api_key_from_env: false,
10565 });
10566 config.features.provider_model_ref = false;
10567 config.defaults = Some(DefaultsConfig {
10568 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10569 fast: None,
10570 task_summary: None,
10571 vision: None,
10572 memory_background: None,
10573 planning: None,
10574 search: None,
10575 code_review: None,
10576 work_review: None,
10577 sub_agent: None,
10578 subagent_models: Default::default(),
10579 });
10580
10581 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10582 }
10583
10584 #[test]
10585 #[allow(clippy::field_reassign_with_default)]
10587 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10588 let mut config = Config::default();
10589 config.provider = "openai".to_string();
10590 config.providers.openai = Some(OpenAIConfig {
10591 api_key: "test".to_string(),
10592 api_key_encrypted: None,
10593 credential_ref: None,
10594 base_url: None,
10595 model: Some("gpt-4o".to_string()),
10596 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10597 vision_model: None,
10598 reasoning_effort: None,
10599 responses_only_models: vec![],
10600 request_overrides: None,
10601 extra: Default::default(),
10602 api_key_from_env: false,
10603 });
10604 config.features.provider_model_ref = true;
10605 config.defaults = Some(DefaultsConfig {
10606 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10607 fast: Some(bamboo_domain::ProviderModelRef::new(
10608 "anthropic",
10609 "claude-3-5-haiku",
10610 )),
10611 task_summary: None,
10612 vision: None,
10613 memory_background: None,
10614 planning: None,
10615 search: None,
10616 code_review: None,
10617 work_review: None,
10618 sub_agent: None,
10619 subagent_models: Default::default(),
10620 });
10621
10622 assert_eq!(
10623 config.get_fast_model(),
10624 Some("claude-3-5-haiku".to_string())
10625 );
10626 }
10627
10628 #[test]
10629 #[allow(clippy::field_reassign_with_default)]
10631 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10632 let mut config = Config::default();
10633 config.provider = "openai".to_string();
10634 config.providers.openai = Some(OpenAIConfig {
10635 api_key: "test".to_string(),
10636 api_key_encrypted: None,
10637 credential_ref: None,
10638 base_url: None,
10639 model: Some("gpt-4o".to_string()),
10640 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10641 vision_model: None,
10642 reasoning_effort: None,
10643 responses_only_models: vec![],
10644 request_overrides: None,
10645 extra: Default::default(),
10646 api_key_from_env: false,
10647 });
10648 config.features.provider_model_ref = false;
10649 config.defaults = Some(DefaultsConfig {
10650 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10651 fast: Some(bamboo_domain::ProviderModelRef::new(
10652 "anthropic",
10653 "claude-3-5-haiku",
10654 )),
10655 task_summary: None,
10656 vision: None,
10657 memory_background: None,
10658 planning: None,
10659 search: None,
10660 code_review: None,
10661 work_review: None,
10662 sub_agent: None,
10663 subagent_models: Default::default(),
10664 });
10665
10666 assert_eq!(
10667 config.get_fast_model(),
10668 Some("legacy-gpt-4o-mini".to_string())
10669 );
10670 }
10671
10672 #[test]
10673 #[allow(clippy::field_reassign_with_default)]
10675 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10676 let mut config = Config::default();
10677 config.provider = "openai".to_string();
10678 config.features.provider_model_ref = true;
10679 config.defaults = Some(DefaultsConfig {
10680 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10681 fast: None,
10682 task_summary: None,
10683 vision: None,
10684 memory_background: None,
10685 planning: None,
10686 search: None,
10687 code_review: None,
10688 work_review: None,
10689 sub_agent: None,
10690 subagent_models: Default::default(),
10691 });
10692
10693 assert_eq!(
10694 config.get_fast_model(),
10695 Some("claude-3-7-sonnet".to_string())
10696 );
10697 }
10698
10699 #[test]
10700 #[allow(clippy::field_reassign_with_default)]
10702 fn get_memory_background_model_prefers_defaults_memory_background() {
10703 let mut config = Config::default();
10704 config.provider = "openai".to_string();
10705 config.providers.openai = Some(OpenAIConfig {
10706 api_key: "test".to_string(),
10707 api_key_encrypted: None,
10708 credential_ref: None,
10709 base_url: None,
10710 model: Some("gpt-4o".to_string()),
10711 fast_model: Some("gpt-4o-mini".to_string()),
10712 vision_model: None,
10713 reasoning_effort: None,
10714 responses_only_models: vec![],
10715 request_overrides: None,
10716 extra: Default::default(),
10717 api_key_from_env: false,
10718 });
10719 config.features.provider_model_ref = true;
10720 config.defaults = Some(DefaultsConfig {
10721 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10722 fast: Some(bamboo_domain::ProviderModelRef::new(
10723 "openai",
10724 "gpt-4o-mini",
10725 )),
10726 task_summary: None,
10727 vision: None,
10728 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10729 "anthropic",
10730 "claude-3-5-haiku",
10731 )),
10732 planning: None,
10733 search: None,
10734 code_review: None,
10735 work_review: None,
10736 sub_agent: None,
10737 subagent_models: Default::default(),
10738 });
10739
10740 assert_eq!(
10741 config.get_memory_background_model(),
10742 Some("claude-3-5-haiku".to_string())
10743 );
10744 }
10745
10746 #[test]
10747 #[allow(clippy::field_reassign_with_default)]
10749 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10750 let mut config = Config::default();
10751 config.provider = "openai".to_string();
10752 config.features.provider_model_ref = true;
10753 config.defaults = Some(DefaultsConfig {
10754 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10755 fast: Some(bamboo_domain::ProviderModelRef::new(
10756 "anthropic",
10757 "claude-3-5-haiku",
10758 )),
10759 task_summary: None,
10760 vision: None,
10761 memory_background: None,
10762 planning: None,
10763 search: None,
10764 code_review: None,
10765 work_review: None,
10766 sub_agent: None,
10767 subagent_models: Default::default(),
10768 });
10769
10770 assert_eq!(
10771 config.get_memory_background_model(),
10772 Some("claude-3-5-haiku".to_string())
10773 );
10774 }
10775
10776 #[test]
10777 #[allow(clippy::field_reassign_with_default)]
10779 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10780 let mut config = Config::default();
10781 config.provider = "openai".to_string();
10782 config.providers.openai = Some(OpenAIConfig {
10783 api_key: "test".to_string(),
10784 api_key_encrypted: None,
10785 credential_ref: None,
10786 base_url: None,
10787 model: Some("gpt-4o".to_string()),
10788 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10789 vision_model: None,
10790 reasoning_effort: None,
10791 responses_only_models: vec![],
10792 request_overrides: None,
10793 extra: Default::default(),
10794 api_key_from_env: false,
10795 });
10796 config.features.provider_model_ref = false;
10797 config.defaults = Some(DefaultsConfig {
10798 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10799 fast: Some(bamboo_domain::ProviderModelRef::new(
10800 "anthropic",
10801 "claude-3-5-haiku",
10802 )),
10803 task_summary: None,
10804 vision: None,
10805 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10806 "anthropic",
10807 "claude-3-5-haiku",
10808 )),
10809 planning: None,
10810 search: None,
10811 code_review: None,
10812 work_review: None,
10813 sub_agent: None,
10814 subagent_models: Default::default(),
10815 });
10816
10817 assert_eq!(
10818 config.get_memory_background_model(),
10819 Some("legacy-gpt-4o-mini".to_string())
10820 );
10821 }
10822
10823 #[test]
10830 fn is_host_trusted_requires_https_scheme() {
10831 let hosts = vec!["github.com/bigduu/".to_string()];
10832 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10833 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10834 }
10835
10836 #[test]
10837 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10838 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10840 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10841 let hosts = vec!["github.com/bigduu/".to_string()];
10843 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10844 }
10845
10846 #[test]
10847 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10848 let hosts = vec!["trusted.example.com".to_string()];
10849 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10850 assert!(!is_host_trusted(
10854 "https://trusted.example.com.evil.com/x",
10855 &hosts
10856 ));
10857 assert!(!is_host_trusted(
10858 "https://trusted.example.comevil.com/x",
10859 &hosts
10860 ));
10861 }
10862
10863 #[test]
10864 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10865 let hosts = vec!["github.com/bigduu/".to_string()];
10867 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10868 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10869 }
10870
10871 #[test]
10872 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10873 let hosts = vec!["example.com".to_string()];
10874 assert!(is_host_trusted("https://example.com/", &hosts));
10875 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10876 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10879 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10880 }
10881
10882 #[test]
10883 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10884 let hosts = vec!["github.com/bigduu/".to_string()];
10885 assert!(is_host_trusted(
10887 "https://someuser@github.com/bigduu/x",
10888 &hosts
10889 ));
10890 assert!(!is_host_trusted(
10893 "https://github.com@evil.com/bigduu/",
10894 &hosts
10895 ));
10896 }
10897
10898 #[test]
10899 fn is_host_trusted_ignores_an_explicit_port() {
10900 let hosts = vec!["github.com/bigduu/".to_string()];
10901 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10902 }
10903
10904 #[test]
10905 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10906 let hosts = vec!["github.com/bigduu/".to_string()];
10907 assert!(!is_host_trusted("not a url at all", &hosts));
10908 assert!(!is_host_trusted("", &hosts));
10909 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10911
10912 #[test]
10913 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10914 let hosts = vec!["github.com/bigduu/".to_string()];
10915 assert!(!is_host_trusted(
10918 "https://github.com/bigduu/../evil/x",
10919 &hosts
10920 ));
10921 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10923 }
10924
10925 #[test]
10926 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10927 let mut config = Config::default();
10928 config.plugin_trust.trusted_hosts = vec![
10929 " GitHub.com/BigDuu/ ".to_string(),
10930 "".to_string(),
10931 " ".to_string(),
10932 "Example.COM".to_string(),
10933 ];
10934 config.normalize_plugin_trust_settings();
10935 assert_eq!(
10936 config.plugin_trust.trusted_hosts,
10937 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10938 );
10939 }
10940
10941 #[test]
10947 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10948 let config = Config::default();
10950 assert_eq!(
10951 config.plugin_trust.enforcement,
10952 PluginTrustEnforcement::Strict
10953 );
10954 assert!(!config.plugin_trust.enforcement_is_off());
10955
10956 let json = serde_json::json!({
10962 "trusted_hosts": ["example.com"],
10963 "trusted_keys": [],
10964 });
10965 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10966 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10967 }
10968
10969 #[test]
10970 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10971 for raw in ["off", "OFF", "Off", " off "] {
10972 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10973 "enforcement": raw,
10974 }))
10975 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10976 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10977 assert!(trust.enforcement_is_off());
10978 }
10979 for raw in ["strict", "STRICT", " Strict "] {
10980 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10981 "enforcement": raw,
10982 }))
10983 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10984 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10985 }
10986
10987 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10988 "enforcement": "nonsense",
10989 }))
10990 .expect_err("an unrecognized string must be rejected, not silently default");
10991 assert!(err.to_string().contains("nonsense"));
10992 }
10993
10994 #[test]
10995 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10996 let trust: PluginTrustConfig =
10999 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
11000 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
11001
11002 let trust: PluginTrustConfig =
11003 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
11004 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
11005 }
11006
11007 #[test]
11008 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
11009 let trust = PluginTrustConfig {
11014 enforcement: PluginTrustEnforcement::Off,
11015 ..PluginTrustConfig::default()
11016 };
11017 let json = serde_json::to_value(&trust).unwrap();
11018 assert_eq!(json["enforcement"], "off");
11019
11020 let trust = PluginTrustConfig {
11021 enforcement: PluginTrustEnforcement::Strict,
11022 ..PluginTrustConfig::default()
11023 };
11024 let json = serde_json::to_value(&trust).unwrap();
11025 assert_eq!(json["enforcement"], "strict");
11026 }
11027
11028 #[test]
11029 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
11030 let mut config = Config::default();
11033 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
11034 config.normalize_plugin_trust_settings();
11035 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
11036 }
11037
11038 #[test]
11039 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11040 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11045 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11046 )));
11047 assert_eq!(
11048 config.plugin_trust.enforcement,
11049 PluginTrustEnforcement::Strict
11050 );
11051
11052 let outcome = crate::dot_path::apply_dot_path_set(
11053 &config,
11054 "plugin_trust.enforcement",
11055 crate::dot_path::parse_cli_value("off"),
11056 )
11057 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11058 assert_eq!(
11059 outcome.config.plugin_trust.enforcement,
11060 PluginTrustEnforcement::Off
11061 );
11062
11063 let outcome = crate::dot_path::apply_dot_path_set(
11065 &outcome.config,
11066 "plugin_trust.enforcement",
11067 crate::dot_path::parse_cli_value("strict"),
11068 )
11069 .expect("setting it back to strict should also round-trip");
11070 assert_eq!(
11071 outcome.config.plugin_trust.enforcement,
11072 PluginTrustEnforcement::Strict
11073 );
11074 }
11075}
11076
11077#[cfg(test)]
11078mod final_evidence_feature_tests {
11079 use super::FeatureFlags;
11080
11081 #[test]
11082 fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11083 let existing: FeatureFlags =
11084 serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11085 assert!(!existing.final_evidence_check);
11086 let enabled: FeatureFlags =
11087 serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11088 let restored: FeatureFlags =
11089 serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11090 assert!(restored.final_evidence_check);
11091 assert!(!FeatureFlags::default().final_evidence_check);
11092 }
11093}