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